From aa632ec6ec2d3fb48876726dc73ce95fc33d5bd3 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Wed, 1 Jul 2026 16:31:11 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=AF=BC=E5=87=BA=E6=97=B6=E5=88=86?= =?UTF-8?q?=E5=88=97=E3=80=82=202=E3=80=81=E5=95=86=E6=9C=BA=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=97=B6=E9=97=B4=E5=8F=AA=E6=9C=89=E9=94=80=E5=94=AE?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE=E6=97=B6=E6=89=8D=E4=BC=9A?= =?UTF-8?q?=E6=94=B9=E5=8F=98=E3=80=82=203=E3=80=81=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=A6=81=E7=BB=99=E5=88=B0=E4=BA=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../17-ece955a03030448596acdf45ae761491.zip | 1 + .../common/OpportunitySchemaInitializer.java | 2 + .../UserDataScopeSchemaInitializer.java | 66 +++ .../UserDataScopeAdminController.java | 64 +++ .../datascope/UserDataScopeAssignmentDTO.java | 81 +++ .../dto/datascope/UserDataScopeGrantDTO.java | 115 ++++ .../dto/datascope/UserDataScopeUserDTO.java | 59 ++ .../opportunity/OpportunityFollowUpDTO.java | 9 + .../crm/dto/work/WorkCheckInExportDTO.java | 19 + .../dto/work/WorkDailyReportExportDTO.java | 19 + .../com/unis/crm/mapper/ExpansionMapper.java | 13 +- .../java/com/unis/crm/mapper/WorkMapper.java | 34 +- .../crm/service/CrmDataVisibilityService.java | 19 + .../service/UserDataScopeAdminService.java | 351 ++++++++++++ .../impl/CrmDataVisibilityServiceImpl.java | 87 +++ .../service/impl/ExpansionServiceImpl.java | 78 ++- .../service/impl/OpportunityServiceImpl.java | 29 +- .../crm/service/impl/WorkServiceImpl.java | 291 ++++++++-- .../src/main/resources/application-prod.yml | 14 - backend/src/main/resources/application.yml | 14 - .../mapper/expansion/ExpansionMapper.xml | 251 ++++++++ .../mapper/opportunity/OpportunityMapper.xml | 19 +- .../main/resources/mapper/work/WorkMapper.xml | 321 +++++++++-- .../UserDataScopeAdminServiceTest.java | 200 +++++++ .../CrmDataVisibilityServiceImplTest.java | 69 +++ .../impl/ExpansionServiceImplTest.java | 27 + .../impl/OpportunityServiceImplTest.java | 11 +- .../crm/service/impl/WorkServiceImplTest.java | 198 ++++++- frontend/src/lib/auth.ts | 120 +++- frontend/src/pages/Opportunities.tsx | 68 ++- frontend1/dist/index.html | 2 +- frontend1/src/api/index.ts | 27 +- frontend1/src/pages/index.ts | 1 + .../pages/system/user-data-scope/index.less | 48 ++ .../pages/system/user-data-scope/index.tsx | 540 ++++++++++++++++++ frontend1/src/routes/routes.tsx | 2 + frontend1/src/types/index.ts | 35 ++ sql/20260701_opportunity_updated_by_pg17.sql | 4 + sql/20260701_user_data_scope_user_pg17.sql | 284 +++++++++ ...01_work_report_button_permissions_pg17.sql | 242 ++++++++ sql/init_full_pg17.sql | 2 + 41 files changed, 3654 insertions(+), 182 deletions(-) create mode 100644 backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip create mode 100644 backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java create mode 100644 backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java create mode 100644 backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java create mode 100644 backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java create mode 100644 backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java create mode 100644 backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java create mode 100644 backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java create mode 100644 backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java create mode 100644 frontend1/src/pages/system/user-data-scope/index.less create mode 100644 frontend1/src/pages/system/user-data-scope/index.tsx create mode 100644 sql/20260701_opportunity_updated_by_pg17.sql create mode 100644 sql/20260701_user_data_scope_user_pg17.sql create mode 100644 sql/20260701_work_report_button_permissions_pg17.sql diff --git a/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip b/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-ece955a03030448596acdf45ae761491.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java index 2d7ab5a1..3d9ee88e 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -34,12 +34,14 @@ public class OpportunitySchemaInitializer implements ApplicationRunner { statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)"); statement.execute("alter table crm_opportunity add column if not exists latest_progress text"); statement.execute("alter table crm_opportunity add column if not exists next_plan text"); + statement.execute("alter table crm_opportunity add column if not exists updated_by bigint"); statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz"); statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)"); statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false"); statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)"); statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'"); statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'"); + statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'"); statement.execute("comment on column crm_opportunity.archived_at is '归档时间'"); statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'"); statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'"); diff --git a/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java new file mode 100644 index 00000000..ba1a235e --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java @@ -0,0 +1,66 @@ +package com.unis.crm.common; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class UserDataScopeSchemaInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(UserDataScopeSchemaInitializer.class); + + private final DataSource dataSource; + + public UserDataScopeSchemaInitializer(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public void run(ApplicationArguments args) { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + create table if not exists sys_user_data_scope_user ( + id bigserial primary key, + tenant_id bigint not null, + viewer_user_id bigint not null, + owner_user_id bigint not null, + resource_type varchar(50) not null default 'ALL', + enabled boolean not null default true, + expire_at timestamptz, + remark varchar(500), + created_by bigint, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + is_deleted integer not null default 0 + ) + """); + statement.execute(""" + create unique index if not exists uk_user_data_scope_user + on sys_user_data_scope_user (tenant_id, viewer_user_id, owner_user_id, resource_type) + where is_deleted = 0 + """); + statement.execute(""" + create index if not exists idx_user_data_scope_viewer + on sys_user_data_scope_user (tenant_id, viewer_user_id, resource_type, enabled) + """); + statement.execute(""" + create index if not exists idx_user_data_scope_owner + on sys_user_data_scope_user (tenant_id, owner_user_id) + """); + statement.execute("comment on table sys_user_data_scope_user is '用户到用户的数据可见范围授权'"); + statement.execute("comment on column sys_user_data_scope_user.viewer_user_id is '被授权查看数据的用户ID'"); + statement.execute("comment on column sys_user_data_scope_user.owner_user_id is '数据归属用户ID'"); + statement.execute("comment on column sys_user_data_scope_user.resource_type is '授权资源类型:OPPORTUNITY/EXPANSION/DAILY_REPORT/CHECKIN等'"); + log.info("Ensured sys_user_data_scope_user exists"); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to initialize user data scope schema", exception); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java new file mode 100644 index 00000000..fd36a001 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java @@ -0,0 +1,64 @@ +package com.unis.crm.controller; + +import com.unis.crm.common.ApiResponse; +import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO; +import com.unis.crm.dto.datascope.UserDataScopeGrantDTO; +import com.unis.crm.dto.datascope.UserDataScopeUserDTO; +import com.unis.crm.service.UserDataScopeAdminService; +import com.unisbase.common.annotation.Log; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +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; + +@RestController +@RequestMapping("/sys/api/admin/user-data-scope") +public class UserDataScopeAdminController { + + private final UserDataScopeAdminService userDataScopeAdminService; + + public UserDataScopeAdminController(UserDataScopeAdminService userDataScopeAdminService) { + this.userDataScopeAdminService = userDataScopeAdminService; + } + + @GetMapping("/users") + public ApiResponse> listUsers( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(userDataScopeAdminService.listUsers(tenantId)); + } + + @GetMapping("/grants") + public ApiResponse> listGrants( + @RequestParam(value = "tenantId", required = false) Long tenantId, + @RequestParam(value = "viewerUserId", required = false) Long viewerUserId, + @RequestParam(value = "resourceType", required = false) String resourceType) { + return ApiResponse.success(userDataScopeAdminService.listGrants(tenantId, viewerUserId, resourceType)); + } + + @GetMapping("/assignment") + public ApiResponse getAssignment( + @RequestParam(value = "tenantId", required = false) Long tenantId, + @RequestParam("viewerUserId") Long viewerUserId, + @RequestParam("resourceType") String resourceType) { + return ApiResponse.success(userDataScopeAdminService.getAssignment(tenantId, viewerUserId, resourceType)); + } + + @PutMapping("/assignment") + @Log(type = "系统管理", value = "保存用户数据授权") + public ApiResponse saveAssignment(@RequestBody UserDataScopeAssignmentDTO payload) { + return ApiResponse.success(userDataScopeAdminService.saveAssignment(payload)); + } + + @DeleteMapping("/grants/{id}") + @Log(type = "系统管理", value = "删除用户数据授权") + public ApiResponse deleteGrant( + @PathVariable("id") Long id, + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(userDataScopeAdminService.deleteGrant(tenantId, id)); + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java new file mode 100644 index 00000000..5f896411 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java @@ -0,0 +1,81 @@ +package com.unis.crm.dto.datascope; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +public class UserDataScopeAssignmentDTO { + + private Long tenantId; + private Long viewerUserId; + private String resourceType; + private List resourceTypes = new ArrayList<>(); + private List ownerUserIds = new ArrayList<>(); + private OffsetDateTime expireAt; + private String remark; + private Boolean enabled; + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public Long getViewerUserId() { + return viewerUserId; + } + + public void setViewerUserId(Long viewerUserId) { + this.viewerUserId = viewerUserId; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public List getResourceTypes() { + return resourceTypes; + } + + public void setResourceTypes(List resourceTypes) { + this.resourceTypes = resourceTypes; + } + + public List getOwnerUserIds() { + return ownerUserIds; + } + + public void setOwnerUserIds(List ownerUserIds) { + this.ownerUserIds = ownerUserIds; + } + + public OffsetDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(OffsetDateTime expireAt) { + this.expireAt = expireAt; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java new file mode 100644 index 00000000..9e00cee7 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java @@ -0,0 +1,115 @@ +package com.unis.crm.dto.datascope; + +import java.time.OffsetDateTime; + +public class UserDataScopeGrantDTO { + + private Long id; + private Long tenantId; + private Long viewerUserId; + private String viewerName; + private Long ownerUserId; + private String ownerName; + private String resourceType; + private Boolean enabled; + private OffsetDateTime expireAt; + private String remark; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public Long getViewerUserId() { + return viewerUserId; + } + + public void setViewerUserId(Long viewerUserId) { + this.viewerUserId = viewerUserId; + } + + public String getViewerName() { + return viewerName; + } + + public void setViewerName(String viewerName) { + this.viewerName = viewerName; + } + + public Long getOwnerUserId() { + return ownerUserId; + } + + public void setOwnerUserId(Long ownerUserId) { + this.ownerUserId = ownerUserId; + } + + public String getOwnerName() { + return ownerName; + } + + public void setOwnerName(String ownerName) { + this.ownerName = ownerName; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public OffsetDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(OffsetDateTime expireAt) { + this.expireAt = expireAt; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java new file mode 100644 index 00000000..a0b604d6 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeUserDTO.java @@ -0,0 +1,59 @@ +package com.unis.crm.dto.datascope; + +public class UserDataScopeUserDTO { + + private Long userId; + private String username; + private String displayName; + private String orgName; + private Integer status; + private Long tenantId; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getOrgName() { + return orgName; + } + + public void setOrgName(String orgName) { + this.orgName = orgName; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java index 41c466e0..03c26933 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java @@ -16,6 +16,7 @@ public class OpportunityFollowUpDTO { private String communicationContent; private String nextAction; private String user; + private String roleCodes; private List attachments = new ArrayList<>(); public Long getId() { @@ -98,6 +99,14 @@ public class OpportunityFollowUpDTO { this.user = user; } + public String getRoleCodes() { + return roleCodes; + } + + public void setRoleCodes(String roleCodes) { + this.roleCodes = roleCodes; + } + public List getAttachments() { return attachments; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java index 6c4bff39..2ec4adf6 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java @@ -1,11 +1,13 @@ package com.unis.crm.dto.work; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; public class WorkCheckInExportDTO { + private Long id; private String checkinDate; private String checkinTime; private String userName; @@ -19,8 +21,17 @@ public class WorkCheckInExportDTO { private String status; private String createdAt; private String updatedAt; + private OffsetDateTime sortTime; private List photoUrls = new ArrayList<>(); + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getCheckinDate() { return checkinDate; } @@ -125,6 +136,14 @@ public class WorkCheckInExportDTO { this.updatedAt = updatedAt; } + public OffsetDateTime getSortTime() { + return sortTime; + } + + public void setSortTime(OffsetDateTime sortTime) { + this.sortTime = sortTime; + } + public List getPhotoUrls() { return photoUrls; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java index c27b4e00..a58bc7c4 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java @@ -2,9 +2,11 @@ package com.unis.crm.dto.work; import java.util.ArrayList; import java.util.List; +import java.time.OffsetDateTime; public class WorkDailyReportExportDTO { + private Long id; private String reportDate; private String submitTime; private String userName; @@ -19,10 +21,19 @@ public class WorkDailyReportExportDTO { private String reviewedAt; private String createdAt; private String updatedAt; + private OffsetDateTime sortTime; private List lineItems = new ArrayList<>(); private List attachments = new ArrayList<>(); private List planItems = new ArrayList<>(); + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + public String getReportDate() { return reportDate; } @@ -135,6 +146,14 @@ public class WorkDailyReportExportDTO { this.updatedAt = updatedAt; } + public OffsetDateTime getSortTime() { + return sortTime; + } + + public void setSortTime(OffsetDateTime sortTime) { + this.sortTime = sortTime; + } + public List getLineItems() { return lineItems; } diff --git a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java index 068415cf..bc967732 100644 --- a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java @@ -32,9 +32,17 @@ public interface ExpansionMapper { @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") List selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); + List selectSalesExpansionsByOwnerUserIds( + @Param("ownerUserIds") List ownerUserIds, + @Param("keyword") String keyword); + @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); + List selectChannelExpansionsByOwnerUserIds( + @Param("ownerUserIds") List ownerUserIds, + @Param("keyword") String keyword); + List selectSalesExpansionsForTenant( @Param("tenantId") Long tenantId, @Param("keyword") String keyword, @@ -45,19 +53,14 @@ public interface ExpansionMapper { @Param("keyword") String keyword, @Param("limit") Integer limit); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") List selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List bizIds); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List bizIds); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List bizIds); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List bizIds); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List bizIds); int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request); diff --git a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java index b2d8d416..b7631567 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -32,11 +32,23 @@ public interface WorkMapper { @DataScope(tableAlias = "c", ownerColumn = "user_id") List selectCheckInHistory(@Param("limit") int limit); + List selectCheckInHistoryByUserIds( + @Param("visibleUserIds") List visibleUserIds, + @Param("limit") int limit); + @DataScope(tableAlias = "r", ownerColumn = "user_id") List selectReportHistory(@Param("limit") int limit); + List selectReportHistoryByUserIds( + @Param("visibleUserIds") List visibleUserIds, + @Param("limit") int limit); + WorkHistoryItemDTO selectReportHistoryById(@Param("userId") Long userId, @Param("reportId") Long reportId); + WorkHistoryItemDTO selectReportHistoryByIdForUserIds( + @Param("reportId") Long reportId, + @Param("visibleUserIds") List visibleUserIds); + @DataScope(tableAlias = "c", ownerColumn = "user_id") List selectCheckInExportRows( @Param("startDate") LocalDate startDate, @@ -47,6 +59,16 @@ public interface WorkMapper { @Param("status") String status, @Param("limit") int limit); + List selectCheckInExportRowsByUserIds( + @Param("visibleUserIds") List visibleUserIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("keyword") String keyword, + @Param("deptName") String deptName, + @Param("bizType") String bizType, + @Param("status") String status, + @Param("limit") int limit); + @DataScope(tableAlias = "r", ownerColumn = "user_id") List selectDailyReportExportRows( @Param("startDate") LocalDate startDate, @@ -56,6 +78,15 @@ public interface WorkMapper { @Param("status") String status, @Param("limit") int limit); + List selectDailyReportExportRowsByUserIds( + @Param("visibleUserIds") List visibleUserIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("keyword") String keyword, + @Param("deptName") String deptName, + @Param("status") String status, + @Param("limit") int limit); + Long selectTodayCheckInId(@Param("userId") Long userId); int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request); @@ -164,7 +195,8 @@ public interface WorkMapper { @Param("opportunityId") Long opportunityId, @Param("latestProgress") String latestProgress, @Param("nextPlan") String nextPlan, - @Param("stage") String stage); + @Param("stage") String stage, + @Param("updatedBy") Long updatedBy); int deleteTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId); diff --git a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java new file mode 100644 index 00000000..000bbfe2 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java @@ -0,0 +1,19 @@ +package com.unis.crm.service; + +import java.util.List; + +public interface CrmDataVisibilityService { + + String RESOURCE_ALL = "ALL"; + String RESOURCE_OPPORTUNITY = "OPPORTUNITY"; + String RESOURCE_EXPANSION = "EXPANSION"; + String RESOURCE_DAILY_REPORT = "DAILY_REPORT"; + String RESOURCE_CHECKIN = "CHECKIN"; + + DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType); + + record DataVisibility( + boolean allDataAccess, + List visibleOwnerUserIds) { + } +} diff --git a/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java new file mode 100644 index 00000000..397465d8 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java @@ -0,0 +1,351 @@ +package com.unis.crm.service; + +import com.unis.crm.common.BusinessException; +import com.unis.crm.common.UnauthorizedException; +import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO; +import com.unis.crm.dto.datascope.UserDataScopeGrantDTO; +import com.unis.crm.dto.datascope.UserDataScopeUserDTO; +import com.unisbase.security.PermissionService; +import com.unisbase.security.SpringSecurityTenantProvider; +import com.unisbase.security.SpringSecurityUserProvider; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class UserDataScopeAdminService { + + private static final String VIEW_PERM = "user_data_scope:view"; + private static final String UPDATE_PERM = "user_data_scope:update"; + private static final Set RESOURCE_TYPES = Set.of( + "ALL", + "OPPORTUNITY", + "EXPANSION", + "DAILY_REPORT", + "CHECKIN", + "CUSTOMER", + "WORK"); + + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + private final SpringSecurityTenantProvider tenantProvider; + private final SpringSecurityUserProvider userProvider; + + public UserDataScopeAdminService( + JdbcTemplate jdbcTemplate, + PermissionService permissionService, + SpringSecurityTenantProvider tenantProvider, + SpringSecurityUserProvider userProvider) { + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + this.tenantProvider = tenantProvider; + this.userProvider = userProvider; + } + + public List listUsers(Long tenantId) { + Long resolvedTenantId = resolveTenantId(tenantId); + requirePermission(VIEW_PERM, "无权查看数据授权配置"); + return jdbcTemplate.query(""" + select + u.user_id, + coalesce(nullif(u.username, ''), cast(u.user_id as varchar)) as username, + coalesce(nullif(u.display_name, ''), nullif(u.username, ''), cast(u.user_id as varchar)) as display_name, + coalesce(u.status, 1) as status, + tu.tenant_id, + string_agg(distinct o.org_name, '、' order by o.org_name) as org_name + from sys_user u + join sys_tenant_user tu on tu.user_id = u.user_id + left join sys_org o + on o.id = tu.org_id + and o.tenant_id = tu.tenant_id + and coalesce(o.is_deleted, 0) = 0 + where tu.tenant_id = ? + and coalesce(u.is_deleted, 0) = 0 + and coalesce(tu.is_deleted, 0) = 0 + group by u.user_id, u.username, u.display_name, u.status, tu.tenant_id + order by coalesce(u.status, 1) desc, display_name asc, username asc, u.user_id asc + """, (resultSet, rowNum) -> mapUser(resultSet), resolvedTenantId); + } + + public List listGrants(Long tenantId, Long viewerUserId, String resourceType) { + Long resolvedTenantId = resolveTenantId(tenantId); + requirePermission(VIEW_PERM, "无权查看数据授权配置"); + String normalizedResourceType = normalizeResourceType(resourceType, false); + List args = new ArrayList<>(); + args.add(resolvedTenantId); + StringBuilder sql = new StringBuilder(""" + select + g.id, + g.tenant_id, + g.viewer_user_id, + coalesce(nullif(viewer.display_name, ''), nullif(viewer.username, ''), cast(g.viewer_user_id as varchar)) as viewer_name, + g.owner_user_id, + coalesce(nullif(owner_user.display_name, ''), nullif(owner_user.username, ''), cast(g.owner_user_id as varchar)) as owner_name, + g.resource_type, + g.enabled, + g.expire_at, + g.remark, + g.created_at, + g.updated_at + from sys_user_data_scope_user g + left join sys_user viewer on viewer.user_id = g.viewer_user_id + left join sys_user owner_user on owner_user.user_id = g.owner_user_id + where g.tenant_id = ? + and coalesce(g.is_deleted, 0) = 0 + """); + if (viewerUserId != null && viewerUserId > 0) { + sql.append(" and g.viewer_user_id = ?"); + args.add(viewerUserId); + } + if (normalizedResourceType != null) { + sql.append(" and g.resource_type = ?"); + args.add(normalizedResourceType); + } + sql.append(" order by viewer_name asc, g.resource_type asc, owner_name asc, g.id asc"); + return jdbcTemplate.query(sql.toString(), (resultSet, rowNum) -> mapGrant(resultSet), args.toArray()); + } + + public UserDataScopeAssignmentDTO getAssignment(Long tenantId, Long viewerUserId, String resourceType) { + Long resolvedTenantId = resolveTenantId(tenantId); + requirePermission(VIEW_PERM, "无权查看数据授权配置"); + Long normalizedViewerUserId = requirePositiveUserId(viewerUserId, "请选择查看人"); + String normalizedResourceType = normalizeResourceType(resourceType, true); + requireUserInTenant(resolvedTenantId, normalizedViewerUserId, "查看人不存在或不属于当前租户"); + + List grants = listGrants(resolvedTenantId, normalizedViewerUserId, normalizedResourceType); + UserDataScopeAssignmentDTO dto = new UserDataScopeAssignmentDTO(); + dto.setTenantId(resolvedTenantId); + dto.setViewerUserId(normalizedViewerUserId); + dto.setResourceType(normalizedResourceType); + dto.setResourceTypes(List.of(normalizedResourceType)); + dto.setOwnerUserIds(grants.stream() + .map(UserDataScopeGrantDTO::getOwnerUserId) + .filter(id -> id != null && id > 0) + .distinct() + .toList()); + grants.stream().findFirst().ifPresent(first -> { + dto.setEnabled(first.getEnabled()); + dto.setExpireAt(first.getExpireAt()); + dto.setRemark(first.getRemark()); + }); + if (dto.getEnabled() == null) { + dto.setEnabled(Boolean.TRUE); + } + return dto; + } + + @Transactional + public boolean saveAssignment(UserDataScopeAssignmentDTO payload) { + requirePermission(UPDATE_PERM, "无权修改数据授权配置"); + if (payload == null) { + throw new BusinessException("请提交数据授权配置"); + } + Long tenantId = resolveTenantId(payload.getTenantId()); + Long viewerUserId = requirePositiveUserId(payload.getViewerUserId(), "请选择查看人"); + List resourceTypes = normalizeResourceTypes(payload.getResourceTypes(), payload.getResourceType()); + requireUserInTenant(tenantId, viewerUserId, "查看人不存在或不属于当前租户"); + + List ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId); + validateOwnersInTenant(tenantId, ownerUserIds); + boolean enabled = payload.getEnabled() == null || Boolean.TRUE.equals(payload.getEnabled()); + Long createdBy = userProvider == null ? null : userProvider.getCurrentUserId(); + + for (String resourceType : resourceTypes) { + jdbcTemplate.update(""" + update sys_user_data_scope_user + set enabled = false, + is_deleted = 1, + updated_at = now() + where tenant_id = ? + and viewer_user_id = ? + and resource_type = ? + and coalesce(is_deleted, 0) = 0 + """, tenantId, viewerUserId, resourceType); + + for (Long ownerUserId : ownerUserIds) { + jdbcTemplate.update(""" + insert into sys_user_data_scope_user ( + tenant_id, + viewer_user_id, + owner_user_id, + resource_type, + enabled, + expire_at, + remark, + created_by, + created_at, + updated_at, + is_deleted + ) values (?, ?, ?, ?, ?, ?, ?, ?, now(), now(), 0) + """, + tenantId, + viewerUserId, + ownerUserId, + resourceType, + enabled, + payload.getExpireAt(), + trimToNull(payload.getRemark()), + createdBy); + } + } + return true; + } + + @Transactional + public boolean deleteGrant(Long tenantId, Long grantId) { + requirePermission(UPDATE_PERM, "无权修改数据授权配置"); + Long resolvedTenantId = resolveTenantId(tenantId); + if (grantId == null || grantId <= 0) { + throw new BusinessException("数据授权记录不存在"); + } + int updated = jdbcTemplate.update(""" + update sys_user_data_scope_user + set enabled = false, + is_deleted = 1, + updated_at = now() + where id = ? + and tenant_id = ? + and coalesce(is_deleted, 0) = 0 + """, grantId, resolvedTenantId); + if (updated <= 0) { + throw new BusinessException("数据授权记录不存在或已删除"); + } + return true; + } + + private UserDataScopeUserDTO mapUser(ResultSet resultSet) throws SQLException { + UserDataScopeUserDTO dto = new UserDataScopeUserDTO(); + dto.setUserId(resultSet.getLong("user_id")); + dto.setUsername(resultSet.getString("username")); + dto.setDisplayName(resultSet.getString("display_name")); + dto.setStatus(resultSet.getInt("status")); + dto.setTenantId(resultSet.getLong("tenant_id")); + dto.setOrgName(resultSet.getString("org_name")); + return dto; + } + + private UserDataScopeGrantDTO mapGrant(ResultSet resultSet) throws SQLException { + UserDataScopeGrantDTO dto = new UserDataScopeGrantDTO(); + dto.setId(resultSet.getLong("id")); + dto.setTenantId(resultSet.getLong("tenant_id")); + dto.setViewerUserId(resultSet.getLong("viewer_user_id")); + dto.setViewerName(resultSet.getString("viewer_name")); + dto.setOwnerUserId(resultSet.getLong("owner_user_id")); + dto.setOwnerName(resultSet.getString("owner_name")); + dto.setResourceType(resultSet.getString("resource_type")); + dto.setEnabled(resultSet.getBoolean("enabled")); + dto.setExpireAt(resultSet.getObject("expire_at", OffsetDateTime.class)); + dto.setRemark(resultSet.getString("remark")); + dto.setCreatedAt(resultSet.getObject("created_at", OffsetDateTime.class)); + dto.setUpdatedAt(resultSet.getObject("updated_at", OffsetDateTime.class)); + return dto; + } + + private Long resolveTenantId(Long requestedTenantId) { + Long currentTenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId(); + if (currentTenantId != null && currentTenantId > 0) { + if (requestedTenantId != null && requestedTenantId > 0 && !currentTenantId.equals(requestedTenantId)) { + throw new BusinessException("只能配置当前租户的数据授权"); + } + return currentTenantId; + } + if (requestedTenantId != null && requestedTenantId > 0) { + return requestedTenantId; + } + throw new BusinessException("请先切换到具体租户后再配置数据授权"); + } + + private void requirePermission(String perm, String message) { + if (!permissionService.hasPermi(perm)) { + throw new UnauthorizedException(message); + } + } + + private Long requirePositiveUserId(Long userId, String message) { + if (userId == null || userId <= 0) { + throw new BusinessException(message); + } + return userId; + } + + private void requireUserInTenant(Long tenantId, Long userId, String message) { + Integer count = jdbcTemplate.queryForObject(""" + select count(1) + from sys_user u + join sys_tenant_user tu on tu.user_id = u.user_id + where tu.tenant_id = ? + and u.user_id = ? + and coalesce(u.is_deleted, 0) = 0 + and coalesce(tu.is_deleted, 0) = 0 + """, Integer.class, tenantId, userId); + if (count == null || count <= 0) { + throw new BusinessException(message); + } + } + + private void validateOwnersInTenant(Long tenantId, List ownerUserIds) { + for (Long ownerUserId : ownerUserIds) { + requireUserInTenant(tenantId, ownerUserId, "可见人员不存在或不属于当前租户"); + } + } + + private List normalizeOwnerUserIds(List ownerUserIds, Long viewerUserId) { + if (ownerUserIds == null || ownerUserIds.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (Long ownerUserId : ownerUserIds) { + if (ownerUserId != null && ownerUserId > 0 && !ownerUserId.equals(viewerUserId)) { + normalized.add(ownerUserId); + } + } + return List.copyOf(normalized); + } + + private String normalizeResourceType(String resourceType, boolean required) { + String normalized = trimToNull(resourceType); + if (normalized == null) { + if (required) { + throw new BusinessException("请选择授权资源范围"); + } + return null; + } + normalized = normalized.toUpperCase(Locale.ROOT); + if (!RESOURCE_TYPES.contains(normalized)) { + throw new BusinessException("不支持的授权资源范围"); + } + return normalized; + } + + private List normalizeResourceTypes(List resourceTypes, String fallbackResourceType) { + Set normalized = new LinkedHashSet<>(); + if (resourceTypes != null) { + for (String resourceType : resourceTypes) { + String value = normalizeResourceType(resourceType, false); + if (value != null) { + normalized.add(value); + } + } + } + if (normalized.isEmpty()) { + normalized.add(normalizeResourceType(fallbackResourceType, true)); + } + return List.copyOf(normalized); + } + + private String trimToNull(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } +} diff --git a/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java new file mode 100644 index 00000000..31f38fa7 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java @@ -0,0 +1,87 @@ +package com.unis.crm.service.impl; + +import com.unis.crm.service.CrmDataVisibilityService; +import com.unisbase.dto.DataScopeRuleDTO; +import com.unisbase.service.DataScopeService; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Service +public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService { + + private final DataScopeService dataScopeService; + private final JdbcTemplate jdbcTemplate; + + public CrmDataVisibilityServiceImpl(DataScopeService dataScopeService, JdbcTemplate jdbcTemplate) { + this.dataScopeService = dataScopeService; + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType) { + if (currentUserId == null || currentUserId <= 0) { + return new DataVisibility(false, List.of()); + } + if (tenantId == null || tenantId <= 0 || dataScopeService == null) { + return new DataVisibility(false, List.of(currentUserId)); + } + + DataScopeRuleDTO baseRule = dataScopeService.resolveUserScope(currentUserId, tenantId); + if (baseRule != null && baseRule.isAllAccess()) { + return new DataVisibility(true, List.of()); + } + + Set visibleOwnerUserIds = new LinkedHashSet<>(); + if (baseRule == null) { + visibleOwnerUserIds.add(currentUserId); + } else { + addValidUserIds(visibleOwnerUserIds, baseRule.getCreatorUserIds()); + } + addValidUserIds(visibleOwnerUserIds, queryExplicitVisibleOwnerUserIds(currentUserId, tenantId, resourceType)); + + return new DataVisibility(false, List.copyOf(visibleOwnerUserIds)); + } + + private List queryExplicitVisibleOwnerUserIds(Long currentUserId, Long tenantId, String resourceType) { + String normalizedResourceType = normalizeResourceType(resourceType); + return jdbcTemplate.queryForList(""" + select distinct owner_user_id + from sys_user_data_scope_user + where tenant_id = ? + and viewer_user_id = ? + and owner_user_id > 0 + and enabled = true + and coalesce(is_deleted, 0) = 0 + and (expire_at is null or expire_at > now()) + and resource_type in (?, ?) + order by owner_user_id asc + """, + Long.class, + tenantId, + currentUserId, + RESOURCE_ALL, + normalizedResourceType); + } + + private void addValidUserIds(Set target, List source) { + if (source == null || source.isEmpty()) { + return; + } + for (Long userId : source) { + if (userId != null && userId > 0) { + target.add(userId); + } + } + } + + private String normalizeResourceType(String resourceType) { + if (resourceType == null || resourceType.trim().isEmpty()) { + return RESOURCE_ALL; + } + return resourceType.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java index 4578f74a..996460a7 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java @@ -18,6 +18,8 @@ import com.unis.crm.dto.expansion.SalesExpansionItemDTO; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.mapper.ExpansionMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unis.crm.service.ExpansionService; import com.unisbase.security.PermissionService; import com.unisbase.security.SpringSecurityTenantProvider; @@ -57,14 +59,17 @@ public class ExpansionServiceImpl implements ExpansionService { private final ExpansionMapper expansionMapper; private final SpringSecurityTenantProvider tenantProvider; private final PermissionService permissionService; + private final CrmDataVisibilityService crmDataVisibilityService; public ExpansionServiceImpl( ExpansionMapper expansionMapper, SpringSecurityTenantProvider tenantProvider, - PermissionService permissionService) { + PermissionService permissionService, + CrmDataVisibilityService crmDataVisibilityService) { this.expansionMapper = expansionMapper; this.tenantProvider = tenantProvider; this.permissionService = permissionService; + this.crmDataVisibilityService = crmDataVisibilityService; } @Override @@ -102,8 +107,17 @@ public class ExpansionServiceImpl implements ExpansionService { @Override public ExpansionOverviewDTO getOverview(Long userId, String keyword) { String normalizedKeyword = normalizeKeyword(keyword); - List salesItems = expansionMapper.selectSalesExpansions(userId, normalizedKeyword); - List channelItems = expansionMapper.selectChannelExpansions(userId, normalizedKeyword); + List extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId); + List salesItems = mergeSalesExpansionItems( + expansionMapper.selectSalesExpansions(userId, normalizedKeyword), + extraVisibleOwnerUserIds.isEmpty() + ? List.of() + : expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword)); + List channelItems = mergeChannelExpansionItems( + expansionMapper.selectChannelExpansions(userId, normalizedKeyword), + extraVisibleOwnerUserIds.isEmpty() + ? List.of() + : expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword)); attachSalesFollowUps(userId, salesItems); attachSalesRelatedProjects(userId, salesItems); @@ -115,6 +129,64 @@ public class ExpansionServiceImpl implements ExpansionService { return new ExpansionOverviewDTO(salesItems, channelItems); } + private List resolveExtraVisibleOwnerUserIds(Long userId) { + if (userId == null || userId <= 0 || crmDataVisibilityService == null) { + return List.of(); + } + Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId(); + if (tenantId == null || tenantId <= 0) { + return List.of(); + } + DataVisibility visibility = crmDataVisibilityService.resolveVisibility( + userId, + tenantId, + CrmDataVisibilityService.RESOURCE_EXPANSION); + if (visibility.allDataAccess()) { + return List.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + for (Long ownerUserId : visibility.visibleOwnerUserIds()) { + if (ownerUserId != null && ownerUserId > 0 && !ownerUserId.equals(userId)) { + normalized.add(ownerUserId); + } + } + return List.copyOf(normalized); + } + + private List mergeSalesExpansionItems( + List scopedItems, + List extraItems) { + Map byId = new LinkedHashMap<>(); + for (SalesExpansionItemDTO item : scopedItems) { + if (item != null && item.getId() != null) { + byId.putIfAbsent(item.getId(), item); + } + } + for (SalesExpansionItemDTO item : extraItems) { + if (item != null && item.getId() != null) { + byId.putIfAbsent(item.getId(), item); + } + } + return new ArrayList<>(byId.values()); + } + + private List mergeChannelExpansionItems( + List scopedItems, + List extraItems) { + Map byId = new LinkedHashMap<>(); + for (ChannelExpansionItemDTO item : scopedItems) { + if (item != null && item.getId() != null) { + byId.putIfAbsent(item.getId(), item); + } + } + for (ChannelExpansionItemDTO item : extraItems) { + if (item != null && item.getId() != null) { + byId.putIfAbsent(item.getId(), item); + } + } + return new ArrayList<>(byId.values()); + } + @Override public ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit) { if (userId == null || userId <= 0) { diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 819acce4..f2df9f50 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -21,11 +21,11 @@ import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.mapper.OpportunityMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unis.crm.service.OmsClient; import com.unis.crm.service.OpportunityService; -import com.unisbase.dto.DataScopeRuleDTO; import com.unisbase.security.PermissionService; -import com.unisbase.service.DataScopeService; import com.unisbase.spi.UnisBaseTenantProvider; import java.io.IOException; import java.math.BigDecimal; @@ -63,7 +63,7 @@ public class OpportunityServiceImpl implements OpportunityService { private final OpportunityMapper opportunityMapper; private final OmsClient omsClient; private final ObjectMapper objectMapper; - private final DataScopeService dataScopeService; + private final CrmDataVisibilityService crmDataVisibilityService; private final UnisBaseTenantProvider tenantProvider; private final PermissionService permissionService; @@ -71,13 +71,13 @@ public class OpportunityServiceImpl implements OpportunityService { OpportunityMapper opportunityMapper, OmsClient omsClient, ObjectMapper objectMapper, - DataScopeService dataScopeService, + CrmDataVisibilityService crmDataVisibilityService, UnisBaseTenantProvider tenantProvider, PermissionService permissionService) { this.opportunityMapper = opportunityMapper; this.omsClient = omsClient; this.objectMapper = objectMapper; - this.dataScopeService = dataScopeService; + this.crmDataVisibilityService = crmDataVisibilityService; this.tenantProvider = tenantProvider; this.permissionService = permissionService; } @@ -318,18 +318,19 @@ public class OpportunityServiceImpl implements OpportunityService { } List preSalesUserNames = resolveCurrentUserPreSalesNames(userId); Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId(); - if (tenantId == null || dataScopeService == null) { + if (tenantId == null || crmDataVisibilityService == null) { return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames); } - DataScopeRuleDTO rule = dataScopeService.resolveUserScope(userId, tenantId); - if (rule == null) { - return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames); - } - if (rule.isAllAccess()) { - return new OpportunityVisibility(true, List.of(), userId, preSalesUserNames); - } - return new OpportunityVisibility(false, normalizeVisibleOwnerUserIds(rule.getCreatorUserIds()), userId, preSalesUserNames); + DataVisibility visibility = crmDataVisibilityService.resolveVisibility( + userId, + tenantId, + CrmDataVisibilityService.RESOURCE_OPPORTUNITY); + return new OpportunityVisibility( + visibility.allDataAccess(), + normalizeVisibleOwnerUserIds(visibility.visibleOwnerUserIds()), + userId, + preSalesUserNames); } private List resolveCurrentUserPreSalesNames(Long userId) { diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index e78fad97..6c3d1227 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -24,8 +24,11 @@ import com.unis.crm.dto.work.WorkSuggestedActionDTO; import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unis.crm.service.ReportReminderService; import com.unis.crm.service.WorkService; +import com.unisbase.service.SysPermissionService; import com.unisbase.spi.UnisBaseTenantProvider; import java.io.IOException; import java.math.BigDecimal; @@ -49,9 +52,11 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; @@ -59,6 +64,7 @@ import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -83,6 +89,9 @@ public class WorkServiceImpl implements WorkService { private static final String REPORT_ATTACHMENT_METADATA_SUFFIX = "[[/WORK_REPORT_ATTACHMENTS]]"; private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL); private static final String ONLY_SEE_ROLE_CODE = "only_see"; + private static final String SALES_ROLE_CODE = "xs_col"; + private static final String DAILY_REPORT_NOTIFY_USERS_PERMISSION = "daily_report:notify_users"; + private static final String DAILY_REPORT_ATTACHMENTS_PERMISSION = "daily_report:attachments"; private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报"; private static final String LEGACY_NEXT_PLAN_LABEL = "后续规划"; private static final String OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划"; @@ -105,8 +114,11 @@ public class WorkServiceImpl implements WorkService { private final HttpClient httpClient; private final ObjectMapper objectMapper; private final UnisBaseTenantProvider tenantProvider; + private final CrmDataVisibilityService crmDataVisibilityService; private final ReportReminderService reportReminderService; private final WorkReportProperties workReportProperties; + private final SysPermissionService sysPermissionService; + private final JdbcTemplate jdbcTemplate; private final Path checkInPhotoDirectory; private final Path reportAttachmentDirectory; private final String tencentMapKey; @@ -120,6 +132,9 @@ public class WorkServiceImpl implements WorkService { WorkReportProperties workReportProperties, ObjectMapper objectMapper, UnisBaseTenantProvider tenantProvider, + CrmDataVisibilityService crmDataVisibilityService, + SysPermissionService sysPermissionService, + JdbcTemplate jdbcTemplate, @Value("${unisbase.app.upload-path}") String uploadPath, @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) { this.workMapper = workMapper; @@ -129,6 +144,9 @@ public class WorkServiceImpl implements WorkService { this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties; this.objectMapper = objectMapper; this.tenantProvider = tenantProvider; + this.crmDataVisibilityService = crmDataVisibilityService; + this.sysPermissionService = sysPermissionService; + this.jdbcTemplate = jdbcTemplate; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)) .build(); @@ -151,7 +169,7 @@ public class WorkServiceImpl implements WorkService { @Override public List listReportMessageUsers(Long userId, String keyword) { requireUser(userId); - if (!isDailyReportNotifyUsersVisible(loadUserRoleCodes(userId))) { + if (!isDailyReportNotifyUsersVisible(userId, loadUserRoleCodes(userId))) { throw new BusinessException("当前角色不可使用日报通知用户功能"); } return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50); @@ -163,8 +181,8 @@ public class WorkServiceImpl implements WorkService { List roleCodes = loadUserRoleCodes(userId); return new WorkDailyReportRequirementDTO( isDailyReportObjectSelectionRequired(roleCodes), - isDailyReportNotifyUsersVisible(roleCodes), - isDailyReportAttachmentsVisible(roleCodes)); + isDailyReportNotifyUsersVisible(userId, roleCodes), + isDailyReportAttachmentsVisible(userId, roleCodes)); } @Override @@ -175,7 +193,7 @@ public class WorkServiceImpl implements WorkService { int offset = (safePage - 1) * safeSize; HistoryType historyType = mapHistoryType(type); int fetchLimit = offset + safeSize + 1; - List historyItems = loadHistoryItems(historyType, fetchLimit); + List historyItems = loadHistoryItems(userId, historyType, fetchLimit); boolean hasMore = historyItems.size() > offset + safeSize; if (offset >= historyItems.size()) { @@ -195,6 +213,12 @@ public class WorkServiceImpl implements WorkService { throw new BusinessException("日报不存在"); } WorkHistoryItemDTO item = workMapper.selectReportHistoryById(userId, reportId); + if (item == null) { + List visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_DAILY_REPORT); + if (!visibleUserIds.isEmpty()) { + item = workMapper.selectReportHistoryByIdForUserIds(reportId, visibleUserIds); + } + } if (item == null) { throw new BusinessException("日报不存在或无权查看"); } @@ -214,14 +238,29 @@ public class WorkServiceImpl implements WorkService { requireUser(userId); validateDateRange(startDate, endDate); String normalizedBizType = normalizeBizType(bizType); - List rows = workMapper.selectCheckInExportRows( - startDate, - endDate, - normalizeOptionalText(keyword), - normalizeOptionalText(deptName), - normalizedBizType, - normalizeOptionalText(status), - EXPORT_LIMIT); + List extraVisibleUserIds = resolveExtraVisibleUserIds( + userId, + CrmDataVisibilityService.RESOURCE_CHECKIN); + List rows = limitMergedWorkCheckInExportRows( + workMapper.selectCheckInExportRows( + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizedBizType, + normalizeOptionalText(status), + EXPORT_LIMIT), + extraVisibleUserIds.isEmpty() + ? List.of() + : workMapper.selectCheckInExportRowsByUserIds( + extraVisibleUserIds, + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizedBizType, + normalizeOptionalText(status), + EXPORT_LIMIT)); normalizeCheckInExportRows(rows); return rows; } @@ -238,13 +277,27 @@ public class WorkServiceImpl implements WorkService { requireUser(userId); validateDateRange(startDate, endDate); String normalizedBizType = normalizeBizType(bizType); - List rows = workMapper.selectDailyReportExportRows( - startDate, - endDate, - normalizeOptionalText(keyword), - normalizeOptionalText(deptName), - normalizeOptionalText(status), - EXPORT_LIMIT); + List extraVisibleUserIds = resolveExtraVisibleUserIds( + userId, + CrmDataVisibilityService.RESOURCE_DAILY_REPORT); + List rows = limitMergedWorkDailyReportExportRows( + workMapper.selectDailyReportExportRows( + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizeOptionalText(status), + EXPORT_LIMIT), + extraVisibleUserIds.isEmpty() + ? List.of() + : workMapper.selectDailyReportExportRowsByUserIds( + extraVisibleUserIds, + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizeOptionalText(status), + EXPORT_LIMIT)); normalizeDailyReportExportRows(rows); if (normalizedBizType == null) { return rows; @@ -294,8 +347,8 @@ public class WorkServiceImpl implements WorkService { normalizeDailyReportRequest( request, isDailyReportObjectSelectionRequired(roleCodes), - isDailyReportNotifyUsersVisible(roleCodes), - isDailyReportAttachmentsVisible(roleCodes)); + isDailyReportNotifyUsersVisible(userId, roleCodes), + isDailyReportAttachmentsVisible(userId, roleCodes)); request.setSourceType(normalizeOptionalText(request.getSourceType())); if (request.getSourceType() == null) { request.setSourceType("manual"); @@ -327,7 +380,8 @@ public class WorkServiceImpl implements WorkService { reportId, previousReport == null ? List.of() : previousReport.getLineItems(), request, - currentReport); + currentReport, + isSalesRole(roleCodes)); syncTomorrowPlanTodo(userId, reportId, request.getPlanItems()); syncReportMessages(userId, reportId, resolveReportDate(currentReport), request); Long finalReportId = reportId; @@ -427,7 +481,7 @@ public class WorkServiceImpl implements WorkService { public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) { requireUser(userId); List roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); - if (!isDailyReportAttachmentsVisible(roleCodes)) { + if (!isDailyReportAttachmentsVisible(userId, roleCodes)) { throw new BusinessException("当前角色不可使用日报附件功能"); } if (file == null || file.isEmpty()) { @@ -528,12 +582,24 @@ public class WorkServiceImpl implements WorkService { return dailyReport.isRequiredByDefault(); } - private boolean isDailyReportNotifyUsersVisible(List roleCodes) { - return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getNotifyUsers()); + private boolean isDailyReportNotifyUsersVisible(Long userId, List roleCodes) { + return isPermissionControlledFeatureVisible( + userId, + DAILY_REPORT_NOTIFY_USERS_PERMISSION, + roleCodes, + workReportProperties.getDailyReport().getNotifyUsers()); } - private boolean isDailyReportAttachmentsVisible(List roleCodes) { - return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getAttachments()); + private boolean isDailyReportAttachmentsVisible(Long userId, List roleCodes) { + return isPermissionControlledFeatureVisible( + userId, + DAILY_REPORT_ATTACHMENTS_PERMISSION, + roleCodes, + workReportProperties.getDailyReport().getAttachments()); + } + + private boolean isSalesRole(List roleCodes) { + return roleCodes != null && roleCodes.stream().anyMatch(SALES_ROLE_CODE::equals); } private boolean isFeatureVisible(List roleCodes, WorkReportProperties.FeatureVisibility visibility) { @@ -549,6 +615,45 @@ public class WorkServiceImpl implements WorkService { return safeVisibility.isVisibleByDefault(); } + private boolean isPermissionControlledFeatureVisible( + Long userId, + String permissionCode, + List roleCodes, + WorkReportProperties.FeatureVisibility fallbackVisibility) { + if (!permissionCodeExists(permissionCode)) { + return isFeatureVisible(roleCodes, fallbackVisibility); + } + return loadPermissionCodes(userId).contains(permissionCode); + } + + private Set loadPermissionCodes(Long userId) { + try { + Long tenantId = resolveCurrentTenantId(); + if (tenantId == null || sysPermissionService == null) { + return Set.of(); + } + Set permissionCodes = sysPermissionService.listPermissionCodesByUserId(userId, tenantId); + return permissionCodes == null ? Set.of() : permissionCodes; + } catch (Exception ignored) { + return Set.of(); + } + } + + private boolean permissionCodeExists(String permissionCode) { + try { + if (jdbcTemplate == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0", + Integer.class, + permissionCode); + return count != null && count > 0; + } catch (Exception ignored) { + return false; + } + } + private boolean matchesConfiguredRole(List userRoleCodes, List configuredRoleCodes) { if (userRoleCodes == null || userRoleCodes.isEmpty() || configuredRoleCodes == null || configuredRoleCodes.isEmpty()) { return false; @@ -568,18 +673,132 @@ public class WorkServiceImpl implements WorkService { return normalized == null ? null : normalized.toLowerCase(); } - private List loadHistoryItems(HistoryType historyType, int fetchLimit) { + private List loadHistoryItems(Long userId, HistoryType historyType, int fetchLimit) { List historyItems = new ArrayList<>(); if (historyType == null || historyType == HistoryType.CHECK_IN) { historyItems.addAll(workMapper.selectCheckInHistory(fetchLimit)); + List visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_CHECKIN); + if (!visibleUserIds.isEmpty()) { + historyItems.addAll(workMapper.selectCheckInHistoryByUserIds(visibleUserIds, fetchLimit)); + } } if (historyType == null || historyType == HistoryType.REPORT) { historyItems.addAll(workMapper.selectReportHistory(fetchLimit)); + List visibleUserIds = resolveExtraVisibleUserIds(userId, CrmDataVisibilityService.RESOURCE_DAILY_REPORT); + if (!visibleUserIds.isEmpty()) { + historyItems.addAll(workMapper.selectReportHistoryByUserIds(visibleUserIds, fetchLimit)); + } } + historyItems = deduplicateHistoryItems(historyItems); historyItems.sort(buildHistoryComparator()); return historyItems; } + private List resolveExtraVisibleUserIds(Long userId, String resourceType) { + Long currentUserId = userId; + if (currentUserId == null || currentUserId <= 0) { + return List.of(); + } + Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId(); + if (tenantId == null || tenantId <= 0 || crmDataVisibilityService == null) { + return List.of(); + } + DataVisibility visibility = crmDataVisibilityService.resolveVisibility(currentUserId, tenantId, resourceType); + if (visibility.allDataAccess()) { + return List.of(); + } + return normalizeVisibleUserIds(visibility.visibleOwnerUserIds(), currentUserId); + } + + private List normalizeVisibleUserIds(List userIds, Long currentUserId) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (Long userId : userIds) { + if (userId != null && userId > 0 && !userId.equals(currentUserId)) { + normalized.add(userId); + } + } + return List.copyOf(normalized); + } + + private List deduplicateHistoryItems(List items) { + Map byKey = new LinkedHashMap<>(); + for (WorkHistoryItemDTO item : items) { + if (item != null) { + byKey.putIfAbsent(item.getType() + ":" + item.getId(), item); + } + } + return new ArrayList<>(byKey.values()); + } + + private List limitMergedWorkCheckInExportRows( + List scopedRows, + List extraRows) { + Map byKey = new LinkedHashMap<>(); + for (WorkCheckInExportDTO row : scopedRows) { + if (row != null) { + byKey.putIfAbsent(buildCheckInExportRowKey(row), row); + } + } + for (WorkCheckInExportDTO row : extraRows) { + if (row != null) { + byKey.putIfAbsent(buildCheckInExportRowKey(row), row); + } + } + return byKey.values().stream() + .sorted(buildCheckInExportComparator()) + .limit(EXPORT_LIMIT) + .toList(); + } + + private List limitMergedWorkDailyReportExportRows( + List scopedRows, + List extraRows) { + Map byKey = new LinkedHashMap<>(); + for (WorkDailyReportExportDTO row : scopedRows) { + if (row != null) { + byKey.putIfAbsent(buildDailyReportExportRowKey(row), row); + } + } + for (WorkDailyReportExportDTO row : extraRows) { + if (row != null) { + byKey.putIfAbsent(buildDailyReportExportRowKey(row), row); + } + } + return byKey.values().stream() + .sorted(buildDailyReportExportComparator()) + .limit(EXPORT_LIMIT) + .toList(); + } + + private Comparator buildCheckInExportComparator() { + return Comparator + .comparing(WorkCheckInExportDTO::getSortTime, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(WorkCheckInExportDTO::getId, Comparator.nullsLast(Comparator.reverseOrder())); + } + + private Comparator buildDailyReportExportComparator() { + return Comparator + .comparing(WorkDailyReportExportDTO::getSortTime, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(WorkDailyReportExportDTO::getId, Comparator.nullsLast(Comparator.reverseOrder())); + } + + private String buildCheckInExportRowKey(WorkCheckInExportDTO row) { + if (row.getId() != null) { + return "checkin:" + row.getId(); + } + return "checkin:fallback:" + row.getCheckinDate() + "|" + row.getCheckinTime() + "|" + row.getUserName(); + } + + private String buildDailyReportExportRowKey(WorkDailyReportExportDTO row) { + if (row.getId() != null) { + return "daily-report:" + row.getId(); + } + return "daily-report:fallback:" + row.getReportDate() + "|" + row.getSubmitTime() + "|" + row.getUserName(); + } + private HistoryType mapHistoryType(String type) { String normalizedType = normalizeOptionalText(type); if (normalizedType == null) { @@ -1456,7 +1675,8 @@ public class WorkServiceImpl implements WorkService { Long reportId, List previousLineItems, CreateWorkDailyReportRequest request, - WorkDailyReportDTO currentReport) { + WorkDailyReportDTO currentReport, + boolean syncOpportunitySnapshot) { LocalDate reportDate = resolveReportDate(currentReport); for (WorkReportLineItemDTO item : previousLineItems) { @@ -1498,11 +1718,14 @@ public class WorkServiceImpl implements WorkService { WORK_REPORT_FOLLOW_UP_TYPE, appendReportAttachmentMetadata(item.getContent(), item.getAttachments()), resolveOpportunityNextAction(item)); - workMapper.updateOpportunitySnapshot( - item.getBizId(), - normalizeSnapshotText(item.getLatestProgress()), - normalizeSnapshotText(item.getNextPlan()), - item.getStage()); + if (syncOpportunitySnapshot) { + workMapper.updateOpportunitySnapshot( + item.getBizId(), + normalizeSnapshotText(item.getLatestProgress()), + normalizeSnapshotText(item.getNextPlan()), + item.getStage(), + userId); + } continue; } workMapper.deleteDailyReportExpansionFollowUps( diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index af80c8b3..337cad8d 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -89,17 +89,3 @@ unisbase: required-role-codes: [crm_xs] # 允许不使用 #,可直接填写文字日报的角色编码。 optional-role-codes: [] - notify-users: - # 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。 - visible-by-default: true - # 明确显示“选择通知用户”按钮的角色编码。 - visible-role-codes: [] - # 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。 - hidden-role-codes: [] - attachments: - # 未命中下方任何角色配置时,是否默认显示日报附件功能。 - visible-by-default: true - # 明确显示日报附件功能的角色编码。 - visible-role-codes: [] - # 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。 - hidden-role-codes: [crm_xs] diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index bc7f2817..a7c97ebf 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -98,17 +98,3 @@ unisbase: required-role-codes: [crm_xs] # 允许不使用 #,可直接填写文字日报的角色编码。 optional-role-codes: [] - notify-users: - # 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。 - visible-by-default: true - # 明确显示“选择通知用户”按钮的角色编码。 - visible-role-codes: [] - # 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。 - hidden-role-codes: [] - attachments: - # 未命中下方任何角色配置时,是否默认显示日报附件功能。 - visible-by-default: true - # 明确显示日报附件功能的角色编码。 - visible-role-codes: [] - # 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。 - hidden-role-codes: [crm_xs] diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index 7c6935ae..951c391e 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -4,6 +4,231 @@ "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + s.id, + s.owner_user_id as ownerUserId, + coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner, + 'sales' as type, + coalesce(s.employee_no, '无') as employeeNo, + s.candidate_name as name, + coalesce(office_dict.item_value, s.office_name, '') as officeCode, + coalesce(office_dict.item_label, nullif(s.office_name, ''), '无') as officeName, + coalesce(s.mobile, '无') as phone, + coalesce(s.email, '无') as email, + coalesce(s.target_dept, '') as targetDept, + coalesce(nullif(s.target_dept, ''), '无') as dept, + coalesce(industry_dict.item_value, s.industry, '') as industryCode, + coalesce(industry_dict.item_label, nullif(s.industry, ''), '无') as industry, + coalesce(s.title, '无') as title, + s.intent_level as intentLevel, + case s.intent_level + when 'high' then '高' + when 'medium' then '中' + when 'low' then '低' + else '无' + end as intent, + s.stage as stageCode, + case s.stage + when 'initial_contact' then '初步沟通' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '商务谈判' + when 'won' then '已成交' + when 'lost' then '已放弃' + else coalesce(s.stage, '无') + end as stage, + s.has_desktop_exp as hasExp, + s.in_progress as inProgress, + (s.employment_status = 'active') as active, + s.employment_status as employmentStatus, + coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate, + coalesce(to_char(s.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt, + coalesce( + to_char( + case + when sales_followup.latest_followup_time is null then s.updated_at + else greatest(s.updated_at, sales_followup.latest_followup_time) + end, + 'YYYY-MM-DD HH24:MI' + ), + '无' + ) as updatedAt, + coalesce(s.remark, '无') as notes + + + + left join ( + select + f.biz_id, + max(f.followup_time) as latest_followup_time + from crm_expansion_followup f + where f.biz_type = 'sales' + group by f.biz_id + ) sales_followup on sales_followup.biz_id = s.id + left join sys_user u + on u.user_id = s.owner_user_id + and u.is_deleted = 0 + left join sys_dict_item office_dict + on office_dict.type_code = 'tz_bsc' + and office_dict.item_value = s.office_name + and office_dict.status = 1 + and coalesce(office_dict.is_deleted, 0) = 0 + left join sys_dict_item industry_dict + on industry_dict.type_code = 'tz_sshy' + and industry_dict.item_value = s.industry + and industry_dict.status = 1 + and coalesce(industry_dict.is_deleted, 0) = 0 + + + + + and ( + coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%') + or s.candidate_name ilike concat('%', #{keyword}, '%') + or coalesce(office_dict.item_label, '') ilike concat('%', #{keyword}, '%') + or coalesce(s.target_dept, '') ilike concat('%', #{keyword}, '%') + or coalesce(industry_dict.item_label, '') ilike concat('%', #{keyword}, '%') + ) + + + + + c.id, + c.owner_user_id as ownerUserId, + coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner, + 'channel' as type, + coalesce(c.channel_code, '') as channelCode, + c.channel_name as name, + coalesce(c.province, '') as provinceCode, + coalesce(province_area.name, nullif(c.province, ''), '无') as province, + coalesce(c.city, '') as cityCode, + coalesce(city_area.name, nullif(c.city, ''), '无') as city, + coalesce(c.office_address, '无') as officeAddress, + coalesce(c.channel_industry, c.industry, '') as channelIndustryCode, + coalesce(c.channel_industry, c.industry, '无') as channelIndustry, + coalesce(c.certification_level, '无') as certificationLevel, + coalesce(trim(to_char(c.annual_revenue, 'FM999999990.##')), '') as annualRevenue, + case + when c.annual_revenue is null then '无' + else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元' + end as revenue, + coalesce(c.staff_size, 0) as size, + coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName, + coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle, + coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile, + coalesce(to_char(c.contact_established_date, 'YYYY-MM-DD'), '无') as establishedDate, + c.intent_level as intentLevel, + case c.intent_level + when 'high' then '高' + when 'medium' then '中' + when 'low' then '低' + else '无' + end as intent, + coalesce(c.has_desktop_exp, false) as hasDesktopExp, + coalesce(c.channel_attribute, '') as channelAttributeCode, + coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute, + coalesce(c.internal_attribute, '') as internalAttributeCode, + coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute, + c.stage as stageCode, + case c.stage + when 'initial_contact' then '初步接触' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '合作洽谈' + when 'won' then '已合作' + when 'lost' then '已终止' + else coalesce(c.stage, '无') + end as stage, + c.landed_flag as landed, + coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate, + coalesce(to_char(c.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt, + coalesce( + to_char( + case + when channel_followup.latest_followup_time is null then c.updated_at + else greatest(c.updated_at, channel_followup.latest_followup_time) + end, + 'YYYY-MM-DD HH24:MI' + ), + '无' + ) as updatedAt, + coalesce(c.remark, '无') as notes + + + + left join ( + select + f.biz_id, + max(f.followup_time) as latest_followup_time + from crm_expansion_followup f + where f.biz_type = 'channel' + group by f.biz_id + ) channel_followup on channel_followup.biz_id = c.id + left join sys_user u + on u.user_id = c.owner_user_id + and u.is_deleted = 0 + left join lateral ( + select + contact_name, + contact_title, + contact_mobile + from crm_channel_expansion_contact cc + where cc.channel_expansion_id = c.id + order by cc.sort_order asc nulls last, cc.id asc + limit 1 + ) primary_contact on true + left join cnarea province_area + on province_area.level = 1 + and (province_area.name = c.province or province_area.area_code = c.province) + left join cnarea city_area + on city_area.level = 2 + and (city_area.name = c.city or city_area.area_code = c.city) + left join sys_dict_item channel_attribute_dict + on channel_attribute_dict.type_code = 'tz_qdsx' + and channel_attribute_dict.item_value = c.channel_attribute + and channel_attribute_dict.status = 1 + and coalesce(channel_attribute_dict.is_deleted, 0) = 0 + left join sys_dict_item internal_attribute_dict + on internal_attribute_dict.type_code = 'tz_xhsnbsx' + and internal_attribute_dict.item_value = c.internal_attribute + and internal_attribute_dict.status = 1 + and coalesce(internal_attribute_dict.is_deleted, 0) = 0 + + + + + and ( + c.channel_name ilike concat('%', #{keyword}, '%') + or coalesce(c.channel_code, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.province, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.city, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.certification_level, '') ilike concat('%', #{keyword}, '%') + or coalesce(province_area.name, '') ilike concat('%', #{keyword}, '%') + or coalesce(city_area.name, '') ilike concat('%', #{keyword}, '%') + or exists ( + select 1 + from sys_dict_item industry_dict + where industry_dict.type_code = 'tz_sshy' + and industry_dict.status = 1 + and coalesce(industry_dict.is_deleted, 0) = 0 + and industry_dict.item_value = any(string_to_array(coalesce(c.channel_industry, c.industry, ''), ',')) + and industry_dict.item_label ilike concat('%', #{keyword}, '%') + ) + or exists ( + select 1 + from crm_channel_expansion_contact cc + where cc.channel_expansion_id = c.id + and ( + coalesce(cc.contact_name, '') ilike concat('%', #{keyword}, '%') + or coalesce(cc.contact_mobile, '') ilike concat('%', #{keyword}, '%') + or coalesce(cc.contact_title, '') ilike concat('%', #{keyword}, '%') + ) + ) + ) + + + + + + + select id, @@ -228,6 +280,42 @@ limit #{limit} + + + + + + + + + + @@ -752,6 +976,7 @@ set latest_progress = #{latestProgress}, next_plan = #{nextPlan}, stage = coalesce(#{stage}, stage), + updated_by = #{updatedBy}, updated_at = now() where id = #{opportunityId} diff --git a/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java new file mode 100644 index 00000000..5e92d366 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java @@ -0,0 +1,200 @@ +package com.unis.crm.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unis.crm.common.BusinessException; +import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO; +import com.unisbase.security.PermissionService; +import com.unisbase.security.SpringSecurityTenantProvider; +import com.unisbase.security.SpringSecurityUserProvider; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +@ExtendWith(MockitoExtension.class) +class UserDataScopeAdminServiceTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private PermissionService permissionService; + + @Mock + private SpringSecurityTenantProvider tenantProvider; + + @Mock + private SpringSecurityUserProvider userProvider; + + @InjectMocks + private UserDataScopeAdminService userDataScopeAdminService; + + @Test + void saveAssignment_shouldReplaceCurrentViewerResourceGrants() { + when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(userProvider.getCurrentUserId()).thenReturn(1L); + mockUserInTenant(100L, 9L); + mockUserInTenant(100L, 2L); + mockUserInTenant(100L, 3L); + + UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO(); + payload.setTenantId(100L); + payload.setViewerUserId(9L); + payload.setResourceType("opportunity"); + payload.setOwnerUserIds(List.of(2L, 3L, 2L, 9L)); + payload.setRemark("临时授权"); + + boolean result = userDataScopeAdminService.saveAssignment(payload); + + assertEquals(true, result); + verify(jdbcTemplate).update( + contains("update sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq("OPPORTUNITY")); + verify(jdbcTemplate).update( + contains("insert into sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq(2L), + eq("OPPORTUNITY"), + eq(true), + eq(null), + eq("临时授权"), + eq(1L)); + verify(jdbcTemplate).update( + contains("insert into sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq(3L), + eq("OPPORTUNITY"), + eq(true), + eq(null), + eq("临时授权"), + eq(1L)); + } + + @Test + void saveAssignment_shouldAllowClearingAllOwners() { + when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + mockUserInTenant(100L, 9L); + + UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO(); + payload.setTenantId(100L); + payload.setViewerUserId(9L); + payload.setResourceType("ALL"); + payload.setOwnerUserIds(List.of()); + + userDataScopeAdminService.saveAssignment(payload); + + verify(jdbcTemplate).update( + contains("update sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq("ALL")); + verify(jdbcTemplate, never()).update( + contains("insert into sys_user_data_scope_user"), + any(Object[].class)); + } + + @Test + void saveAssignment_shouldSaveMultipleResourceTypes() { + when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(userProvider.getCurrentUserId()).thenReturn(1L); + mockUserInTenant(100L, 9L); + mockUserInTenant(100L, 2L); + + UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO(); + payload.setTenantId(100L); + payload.setViewerUserId(9L); + payload.setResourceTypes(List.of("opportunity", "expansion", "opportunity")); + payload.setOwnerUserIds(List.of(2L)); + + boolean result = userDataScopeAdminService.saveAssignment(payload); + + assertEquals(true, result); + verify(jdbcTemplate).update( + contains("update sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq("OPPORTUNITY")); + verify(jdbcTemplate).update( + contains("update sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq("EXPANSION")); + verify(jdbcTemplate).update( + contains("insert into sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq(2L), + eq("OPPORTUNITY"), + eq(true), + eq(null), + eq(null), + eq(1L)); + verify(jdbcTemplate).update( + contains("insert into sys_user_data_scope_user"), + eq(100L), + eq(9L), + eq(2L), + eq("EXPANSION"), + eq(true), + eq(null), + eq(null), + eq(1L)); + } + + @Test + void saveAssignment_shouldRejectCrossTenantRequestWhenCurrentTenantIsConcrete() { + when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + + UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO(); + payload.setTenantId(200L); + payload.setViewerUserId(9L); + payload.setResourceType("OPPORTUNITY"); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> userDataScopeAdminService.saveAssignment(payload)); + + assertEquals("只能配置当前租户的数据授权", exception.getMessage()); + verify(jdbcTemplate, never()).update(contains("sys_user_data_scope_user"), org.mockito.ArgumentMatchers.any()); + } + + @Test + void deleteGrant_shouldSoftDeleteTenantScopedGrant() { + when(permissionService.hasPermi("user_data_scope:update")).thenReturn(true); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(jdbcTemplate.update(contains("where id = ?"), eq(88L), eq(100L))).thenReturn(1); + + boolean result = userDataScopeAdminService.deleteGrant(100L, 88L); + + assertEquals(true, result); + verify(jdbcTemplate).update(contains("update sys_user_data_scope_user"), eq(88L), eq(100L)); + } + + private void mockUserInTenant(Long tenantId, Long userId) { + when(jdbcTemplate.queryForObject( + contains("from sys_user u"), + eq(Integer.class), + eq(tenantId), + eq(userId))) + .thenReturn(1); + } +} diff --git a/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java new file mode 100644 index 00000000..f7eef460 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java @@ -0,0 +1,69 @@ +package com.unis.crm.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; +import com.unisbase.dto.DataScopeRuleDTO; +import com.unisbase.service.DataScopeService; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +@ExtendWith(MockitoExtension.class) +class CrmDataVisibilityServiceImplTest { + + @Mock + private DataScopeService dataScopeService; + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private CrmDataVisibilityServiceImpl service; + + @Test + void resolveVisibility_shouldMergeBaseScopeAndExplicitUserGrants() { + DataScopeRuleDTO rule = new DataScopeRuleDTO(); + rule.setCreatorUserIds(Arrays.asList(2L, 3L, 2L, null, -1L)); + when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule); + when(jdbcTemplate.queryForList( + anyString(), + eq(Long.class), + eq(100L), + eq(9L), + eq(CrmDataVisibilityService.RESOURCE_ALL), + eq(CrmDataVisibilityService.RESOURCE_OPPORTUNITY))) + .thenReturn(List.of(3L, 4L, 5L)); + + DataVisibility visibility = service.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY); + + assertFalse(visibility.allDataAccess()); + assertEquals(List.of(2L, 3L, 4L, 5L), visibility.visibleOwnerUserIds()); + } + + @Test + void resolveVisibility_shouldKeepAllAccessWhenBaseScopeIsAll() { + DataScopeRuleDTO rule = new DataScopeRuleDTO(); + rule.setAllAccess(true); + when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule); + + DataVisibility visibility = service.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY); + + assertTrue(visibility.allDataAccess()); + assertEquals(List.of(), visibility.visibleOwnerUserIds()); + verify(jdbcTemplate, never()).queryForList(anyString(), eq(Long.class), org.mockito.ArgumentMatchers.any()); + } +} diff --git a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java index 83ea7b6f..ca077364 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java @@ -18,6 +18,8 @@ import com.unis.crm.dto.expansion.SalesExpansionItemDTO; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.mapper.ExpansionMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unisbase.security.PermissionService; import com.unisbase.security.SpringSecurityTenantProvider; import java.math.BigDecimal; @@ -41,9 +43,34 @@ class ExpansionServiceImplTest { @Mock private PermissionService permissionService; + @Mock + private CrmDataVisibilityService crmDataVisibilityService; + @InjectMocks private ExpansionServiceImpl expansionService; + @Test + void getOverview_shouldIncludeExplicitExpansionGrants() { + SalesExpansionItemDTO ownedItem = new SalesExpansionItemDTO(); + ownedItem.setId(11L); + ownedItem.setName("本人拓展"); + SalesExpansionItemDTO grantedItem = new SalesExpansionItemDTO(); + grantedItem.setId(12L); + grantedItem.setName("授权拓展"); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(crmDataVisibilityService.resolveVisibility(29L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION)) + .thenReturn(new DataVisibility(false, List.of(29L, 36L))); + when(expansionMapper.selectSalesExpansions(29L, null)).thenReturn(List.of(ownedItem)); + when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of(grantedItem)); + when(expansionMapper.selectChannelExpansions(29L, null)).thenReturn(List.of()); + when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of()); + ExpansionOverviewDTO result = expansionService.getOverview(29L, null); + + assertEquals(2, result.getSalesItems().size()); + assertEquals(12L, result.getSalesItems().get(1).getId()); + verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null); + } + @Test void createSalesExpansion_shouldRejectDuplicateEmployeeNo() { CreateSalesExpansionRequest request = buildCreateSalesRequest(); diff --git a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java index a44e7a81..6acd38a3 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java @@ -23,10 +23,10 @@ import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import com.unis.crm.mapper.OpportunityMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unis.crm.service.OmsClient; -import com.unisbase.dto.DataScopeRuleDTO; import com.unisbase.security.PermissionService; -import com.unisbase.service.DataScopeService; import com.unisbase.spi.UnisBaseTenantProvider; import java.math.BigDecimal; import java.time.LocalDate; @@ -50,7 +50,7 @@ class OpportunityServiceImplTest { private ObjectMapper objectMapper; @Mock - private DataScopeService dataScopeService; + private CrmDataVisibilityService crmDataVisibilityService; @Mock private UnisBaseTenantProvider tenantProvider; @@ -217,10 +217,9 @@ class OpportunityServiceImplTest { @Test void getOverview_shouldKeepConfiguredDataScopeAndIncludePreSalesVisibility() { - DataScopeRuleDTO rule = new DataScopeRuleDTO(); - rule.setCreatorUserIds(List.of(2L, 3L, 2L)); when(tenantProvider.getCurrentTenantId()).thenReturn(100L); - when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule); + when(crmDataVisibilityService.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY)) + .thenReturn(new DataVisibility(false, List.of(2L, 3L, 2L))); CurrentUserAccountDTO currentUser = new CurrentUserAccountDTO(); currentUser.setUserId(9L); currentUser.setUsername("presales.zhang"); diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index 8f2e79b4..05002b74 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -23,15 +23,21 @@ import com.unis.crm.mapper.OpportunityMapper; import java.time.LocalDate; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; import com.unis.crm.service.ReportReminderService; +import com.unisbase.service.SysPermissionService; import com.unisbase.spi.UnisBaseTenantProvider; import java.util.List; +import java.time.OffsetDateTime; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockMultipartFile; import static org.mockito.ArgumentMatchers.any; @@ -56,6 +62,15 @@ class WorkServiceImplTest { @Mock private UnisBaseTenantProvider tenantProvider; + @Mock + private CrmDataVisibilityService crmDataVisibilityService; + + @Mock + private SysPermissionService sysPermissionService; + + @Mock + private JdbcTemplate jdbcTemplate; + private WorkServiceImpl workService; private WorkReportProperties workReportProperties; @@ -63,7 +78,19 @@ class WorkServiceImplTest { @BeforeEach void setUp() { workReportProperties = new WorkReportProperties(); - workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, workReportProperties, new ObjectMapper(), tenantProvider, "build/test-uploads", ""); + workService = new WorkServiceImpl( + workMapper, + opportunityMapper, + profileMapper, + reportReminderService, + workReportProperties, + new ObjectMapper(), + tenantProvider, + crmDataVisibilityService, + sysPermissionService, + jdbcTemplate, + "build/test-uploads", + ""); } @Test @@ -86,6 +113,23 @@ class WorkServiceImplTest { verify(workMapper).selectReportHistory(3); } + @Test + void getHistory_shouldIncludeExplicitDailyReportGrants() { + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(crmDataVisibilityService.resolveVisibility(17L, 100L, CrmDataVisibilityService.RESOURCE_DAILY_REPORT)) + .thenReturn(new DataVisibility(false, List.of(17L, 36L))); + when(workMapper.selectReportHistory(3)).thenReturn(List.of( + historyItem(21L, "日报", "2026-04-02", "09:15", "本人日报"))); + when(workMapper.selectReportHistoryByUserIds(List.of(36L), 3)).thenReturn(List.of( + historyItem(22L, "日报", "2026-04-03", "09:15", "授权日报"))); + + WorkHistoryPageDTO result = workService.getHistory(17L, "report", 1, 2); + + assertEquals(2, result.getItems().size()); + assertEquals(22L, result.getItems().get(0).getId()); + verify(workMapper).selectReportHistoryByUserIds(List.of(36L), 3); + } + @Test void getHistory_shouldOnlyQueryCheckInHistoryWhenTypeIsCheckin() { when(workMapper.selectCheckInHistory(2)).thenReturn(List.of( @@ -167,6 +211,31 @@ class WorkServiceImplTest { verify(workMapper, never()).selectReportMessageUsers(any(), any(), eq(50)); } + @Test + void getDailyReportRequirement_shouldUsePermissionForButtonsAndConfigForObjectSelection() { + workReportProperties.getDailyReport().setRequiredRoleCodes(List.of("crm_xs")); + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("crm_xs")); + when(tenantProvider.getCurrentTenantId()).thenReturn(100L); + when(jdbcTemplate.queryForObject( + eq("select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0"), + eq(Integer.class), + eq("daily_report:notify_users"))) + .thenReturn(1); + when(jdbcTemplate.queryForObject( + eq("select count(1) from sys_permission where code = ? and coalesce(is_deleted, 0) = 0"), + eq(Integer.class), + eq("daily_report:attachments"))) + .thenReturn(1); + when(sysPermissionService.listPermissionCodesByUserId(17L, 100L)) + .thenReturn(Set.of("daily_report:notify_users")); + + var requirement = workService.getDailyReportRequirement(17L); + + assertEquals(true, requirement.isObjectSelectionRequired()); + assertEquals(true, requirement.isNotifyUsersVisible()); + assertEquals(false, requirement.isAttachmentsVisible()); + } + @Test void saveDailyReport_shouldRejectEmptyContentByDefault() { when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); @@ -248,7 +317,7 @@ class WorkServiceImplTest { requestCaptor.getValue().getWorkContent().startsWith("1. 2026-04-28 整理客户资料,完成重点项目复盘")); verify(workMapper, never()).insertLegacyOpportunityFollowUp(anyLong(), anyLong(), any(), any(), any(), any()); verify(workMapper, never()).insertLegacyExpansionFollowUp(any(), anyLong(), anyLong(), any(), any(), any(), any(), any(), any(), any()); - verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any()); + verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any(), anyLong()); } @Test @@ -331,6 +400,47 @@ class WorkServiceImplTest { assertEquals(List.of("/api/work/checkin-photos/a.jpg", "/api/work/checkin-photos/b.jpg"), result.get(0).getPhotoUrls()); } + @Test + void exportCheckIns_shouldDeduplicateByIdOnly() { + WorkCheckInExportDTO first = new WorkCheckInExportDTO(); + first.setId(101L); + first.setCheckinDate("2026-07-01"); + first.setCheckinTime("09:30"); + first.setUserName("张三"); + first.setSortTime(OffsetDateTime.parse("2026-07-01T09:30:00+08:00")); + WorkCheckInExportDTO second = new WorkCheckInExportDTO(); + second.setId(102L); + second.setCheckinDate("2026-07-01"); + second.setCheckinTime("09:30"); + second.setUserName("张三"); + second.setSortTime(OffsetDateTime.parse("2026-07-01T08:30:00+08:00")); + WorkCheckInExportDTO duplicate = new WorkCheckInExportDTO(); + duplicate.setId(101L); + duplicate.setCheckinDate("2026-07-01"); + duplicate.setCheckinTime("09:30"); + duplicate.setUserName("张三"); + duplicate.setSortTime(OffsetDateTime.parse("2026-07-01T09:30:00+08:00")); + WorkCheckInExportDTO granted = new WorkCheckInExportDTO(); + granted.setId(103L); + granted.setCheckinDate("2026-07-01"); + granted.setCheckinTime("10:00"); + granted.setUserName("王五"); + granted.setSortTime(OffsetDateTime.parse("2026-07-01T10:00:00+08:00")); + + when(workMapper.selectCheckInExportRows(null, null, null, null, null, null, 5000)) + .thenReturn(List.of(first, second)); + when(tenantProvider.getCurrentTenantId()).thenReturn(1L); + when(crmDataVisibilityService.resolveVisibility(17L, 1L, CrmDataVisibilityService.RESOURCE_CHECKIN)) + .thenReturn(new DataVisibility(false, List.of(18L))); + when(workMapper.selectCheckInExportRowsByUserIds(List.of(18L), null, null, null, null, null, null, 5000)) + .thenReturn(List.of(duplicate, granted)); + + List result = workService.exportCheckIns(17L, null, null, null, null, null, null); + + assertEquals(3, result.size()); + assertEquals(List.of(103L, 101L, 102L), result.stream().map(WorkCheckInExportDTO::getId).toList()); + } + @Test void exportDailyReports_shouldCleanMetadataAndFilterByBizType() { WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO(); @@ -364,6 +474,47 @@ class WorkServiceImplTest { assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent()); } + @Test + void exportDailyReports_shouldDeduplicateByIdOnly() { + WorkDailyReportExportDTO first = new WorkDailyReportExportDTO(); + first.setId(201L); + first.setReportDate("2026-07-01"); + first.setSubmitTime("2026-07-01 18:00"); + first.setUserName("李四"); + first.setSortTime(OffsetDateTime.parse("2026-07-01T18:00:00+08:00")); + WorkDailyReportExportDTO second = new WorkDailyReportExportDTO(); + second.setId(202L); + second.setReportDate("2026-07-01"); + second.setSubmitTime("2026-07-01 18:00"); + second.setUserName("李四"); + second.setSortTime(OffsetDateTime.parse("2026-07-01T17:00:00+08:00")); + WorkDailyReportExportDTO duplicate = new WorkDailyReportExportDTO(); + duplicate.setId(201L); + duplicate.setReportDate("2026-07-01"); + duplicate.setSubmitTime("2026-07-01 18:00"); + duplicate.setUserName("李四"); + duplicate.setSortTime(OffsetDateTime.parse("2026-07-01T18:00:00+08:00")); + WorkDailyReportExportDTO granted = new WorkDailyReportExportDTO(); + granted.setId(203L); + granted.setReportDate("2026-07-01"); + granted.setSubmitTime("2026-07-01 18:30"); + granted.setUserName("王五"); + granted.setSortTime(OffsetDateTime.parse("2026-07-01T18:30:00+08:00")); + + when(workMapper.selectDailyReportExportRows(null, null, null, null, null, 5000)) + .thenReturn(List.of(first, second)); + when(tenantProvider.getCurrentTenantId()).thenReturn(1L); + when(crmDataVisibilityService.resolveVisibility(17L, 1L, CrmDataVisibilityService.RESOURCE_DAILY_REPORT)) + .thenReturn(new DataVisibility(false, List.of(18L))); + when(workMapper.selectDailyReportExportRowsByUserIds(List.of(18L), null, null, null, null, null, 5000)) + .thenReturn(List.of(duplicate, granted)); + + List result = workService.exportDailyReports(17L, null, null, null, null, null, null); + + assertEquals(3, result.size()); + assertEquals(List.of(203L, 201L, 202L), result.stream().map(WorkDailyReportExportDTO::getId).toList()); + } + @Test void saveDailyReport_shouldNotUseTomorrowPlanAsOpportunityNextAction() { CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest(); @@ -542,6 +693,7 @@ class WorkServiceImplTest { when(opportunityMapper.selectDictLabel("sj_xmjd", "方案交流")).thenReturn(null); when(opportunityMapper.selectDictValueByLabel("sj_xmjd", "方案交流")).thenReturn("solution_discussion"); when(opportunityMapper.selectDictLabel("sj_xmjd", "solution_discussion")).thenReturn("方案交流"); + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("XS_Col")); when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any())) .thenReturn(1); when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class))) @@ -555,7 +707,47 @@ class WorkServiceImplTest { eq(101L), eq("推进中"), eq("继续推进"), - eq("solution_discussion")); + eq("solution_discussion"), + eq(17L)); + } + + @Test + void saveDailyReport_shouldNotSyncOpportunitySnapshotForNonSalesRole() { + CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest(); + WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest(); + lineItem.setWorkDate("2026-04-28"); + lineItem.setBizType("opportunity"); + lineItem.setBizId(101L); + lineItem.setBizName("项目A"); + lineItem.setEditorText(""" + @商机 项目A + # 项目最新进展:推进中 + # 下一步销售计划:继续推进 + """); + request.setLineItems(List.of(lineItem)); + + WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest(); + planItem.setContent("这是整篇日报的明日计划"); + request.setPlanItems(List.of(planItem)); + request.setWorkContent("占位"); + request.setTomorrowPlan("这是整篇日报的明日计划"); + request.setSourceType("manual"); + + WorkDailyReportDTO currentReport = new WorkDailyReportDTO(); + currentReport.setDate("2026-04-28"); + currentReport.setSubmitTime("2026-04-28 10:00"); + + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("SQ_Col")); + when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any())) + .thenReturn(1); + when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class))) + .thenReturn(null, 501L); + when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class))) + .thenReturn(currentReport); + + workService.saveDailyReport(17L, request); + + verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any(), anyLong()); } private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 89255037..11c6ff02 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -459,6 +459,7 @@ export interface OpportunityFollowUp { communicationContent?: string; nextAction?: string; user?: string; + roleCodes?: string; attachments?: WorkReportAttachment[]; } @@ -816,12 +817,14 @@ interface ApiErrorBody { const LOGIN_PATH = "/login"; const USER_CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000; +const REFRESH_AHEAD_MS = 60 * 1000; const CURRENT_USER_CACHE_KEY = "auth-cache:current-user"; const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview"; const MY_PERMISSIONS_CACHE_KEY = "auth-cache:my-permissions"; const memoryRequestCache = new Map(); const inFlightRequestCache = new Map>(); let authExpiryTimer: number | null = null; +let refreshTokenPromise: Promise | null = null; const AUTH_REQUIRED_MESSAGE_PATTERNS = [ "登录已失效", "重新登录", @@ -894,6 +897,15 @@ function hasAccessTokenExpired(token?: string | null, now = Date.now()) { return expiresAt <= now; } +function isAccessTokenExpiringSoon(token?: string | null, now = Date.now()) { + const expiresAt = getAccessTokenExpiresAt(token); + if (expiresAt === null) { + return true; + } + + return expiresAt - now <= REFRESH_AHEAD_MS; +} + function redirectToLoginForTimeout() { const currentUrl = new URL(window.location.href); if (currentUrl.pathname === LOGIN_PATH && currentUrl.searchParams.get("timeout") === "1") { @@ -927,6 +939,70 @@ function handleUnauthorizedResponse() { redirectToLoginForTimeout(); } +function persistTokenPair(payload: Pick) { + localStorage.setItem("accessToken", payload.accessToken); + localStorage.setItem("refreshToken", payload.refreshToken); + + if (payload.availableTenants) { + localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants)); + } + + const tokenPayload = getAccessTokenPayload(payload.accessToken); + if (tokenPayload?.tenantId !== undefined) { + localStorage.setItem("activeTenantId", String(tokenPayload.tenantId)); + } + + syncAuthExpiryTimer(); +} + +async function refreshAccessToken(): Promise { + if (refreshTokenPromise) { + return refreshTokenPromise; + } + + const refreshToken = localStorage.getItem("refreshToken"); + if (!refreshToken) { + return null; + } + + refreshTokenPromise = request("/api/sys/auth/refresh", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }) + .then((payload) => { + if (!payload.accessToken || !payload.refreshToken) { + throw new Error("刷新登录态失败"); + } + persistTokenPair(payload); + return payload.accessToken; + }) + .catch(() => null) + .finally(() => { + refreshTokenPromise = null; + }); + + return refreshTokenPromise; +} + +async function ensureFreshAccessToken(force = false): Promise { + const token = localStorage.getItem("accessToken"); + if (!force && token && !isAccessTokenExpiringSoon(token)) { + return token; + } + + const refreshedToken = await refreshAccessToken(); + if (refreshedToken) { + return refreshedToken; + } + + if (!token || hasAccessTokenExpired(token) || force) { + handleUnauthorizedResponse(); + return null; + } + + return token; +} + function syncAuthExpiryTimer() { clearAuthExpiryTimer(); @@ -937,13 +1013,14 @@ function syncAuthExpiryTimer() { const expiresAt = getAccessTokenExpiresAt(token); if (expiresAt === null || expiresAt <= Date.now()) { - handleUnauthorizedResponse(); + void ensureFreshAccessToken(true); return; } + const refreshInMs = Math.max(0, expiresAt - Date.now() - REFRESH_AHEAD_MS); authExpiryTimer = window.setTimeout(() => { - handleUnauthorizedResponse(); - }, expiresAt - Date.now()); + void ensureFreshAccessToken(true); + }, refreshInMs); } function tryParseApiBody(rawText: string, contentType?: string | null): (ApiEnvelope & ApiErrorBody) | null { @@ -1045,6 +1122,10 @@ async function request(input: string, init?: RequestInit, withAuth = false): } if (withAuth) { + const token = await ensureFreshAccessToken(); + if (!token) { + throw new Error("登录已失效,请重新登录"); + } applyAuthHeaders(headers); } @@ -1083,12 +1164,26 @@ async function request(input: string, init?: RequestInit, withAuth = false): } export async function fetchWithAuth(input: string, init?: RequestInit) { + const token = await ensureFreshAccessToken(); + if (!token) { + throw new Error("登录已失效,请重新登录"); + } const response = await fetch(input, { ...init, headers: applyAuthHeaders(new Headers(init?.headers)), }); if (shouldTreatAsUnauthorized(response)) { + const refreshedToken = await refreshAccessToken(); + if (refreshedToken) { + const retryResponse = await fetch(input, { + ...init, + headers: applyAuthHeaders(new Headers(init?.headers)), + }); + if (!shouldTreatAsUnauthorized(retryResponse)) { + return retryResponse; + } + } handleUnauthorizedResponse(); throw new Error("登录已失效,请重新登录"); } @@ -1098,7 +1193,7 @@ export async function fetchWithAuth(input: string, init?: RequestInit) { export function isAuthed() { const token = localStorage.getItem("accessToken"); - return Boolean(token) && !hasAccessTokenExpired(token); + return (Boolean(token) && !hasAccessTokenExpired(token)) || Boolean(localStorage.getItem("refreshToken")); } export function clearAuth() { @@ -1114,23 +1209,8 @@ export function clearAuth() { export function persistLogin(payload: TokenResponse, username: string) { clearCachedAuthContext(); - localStorage.setItem("accessToken", payload.accessToken); - localStorage.setItem("refreshToken", payload.refreshToken); localStorage.setItem("username", username); - - if (payload.availableTenants) { - localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants)); - try { - const tokenPayload = getAccessTokenPayload(payload.accessToken); - if (tokenPayload?.tenantId !== undefined) { - localStorage.setItem("activeTenantId", String(tokenPayload.tenantId)); - } - } catch { - localStorage.removeItem("activeTenantId"); - } - } - - syncAuthExpiryTimer(); + persistTokenPair(payload); } function getStoredUserId() { diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 6f69ba7a..185c52b2 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -99,8 +99,9 @@ type OpportunityExportFilters = { operatorName?: string; hasSalesExpansion?: string; hasChannelExpansion?: string; - selectedFields?: OpportunityExportFieldKey[]; + selectedFields?: LegacyOpportunityExportFieldKey[]; }; +type LegacyOpportunityExportFieldKey = OpportunityExportFieldKey | "followUps"; type OpportunityExportFieldKey = | "code" | "name" @@ -119,7 +120,11 @@ type OpportunityExportFieldKey = | "isPoc" | "latestProgress" | "nextPlan" - | "followUps" + | "salesFollowUps" + | "preSalesFollowUps" + | "afterSalesFollowUps" + | "productFollowUps" + | "businessFollowUps" | "salesExpansionName" | "salesExpansionIntent" | "salesExpansionActive" @@ -209,7 +214,7 @@ function loadOpportunityExportPreferences(): OpportunityExportFilters { return {}; } const parsed = JSON.parse(rawValue); - return typeof parsed === "object" && parsed ? parsed as OpportunityExportFilters : {}; + return typeof parsed === "object" && parsed ? normalizeOpportunityExportFilters(parsed as OpportunityExportFilters) : {}; } catch { return {}; } @@ -220,12 +225,22 @@ function persistOpportunityExportPreferences(filters: OpportunityExportFilters) return; } try { - window.localStorage.setItem(OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(filters)); + window.localStorage.setItem(OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(normalizeOpportunityExportFilters(filters))); } catch { // ignore storage failures } } +function normalizeOpportunityExportFilters(filters: OpportunityExportFilters): OpportunityExportFilters { + if (!filters.selectedFields) { + return filters; + } + return { + ...filters, + selectedFields: normalizeOpportunityExportFieldKeys(filters.selectedFields), + }; +} + function normalizeOpportunityExportText(value?: string | number | boolean | null) { if (value === null || value === undefined) { return ""; @@ -290,7 +305,11 @@ const opportunityExportColumns: OpportunityExportColumn[] = [ { key: "competitorName", label: "竞争对手", value: (item) => normalizeOpportunityExportText(item.competitorName) }, { key: "latestProgress", label: "项目最新进展", kind: "longText", value: (item) => normalizeOpportunityExportText(item.latestProgress) }, { key: "nextPlan", label: OPPORTUNITY_NEXT_PLAN_LABEL, kind: "longText", value: (item) => normalizeOpportunityExportText(item.nextPlan) }, - { key: "followUps", label: "跟进记录", kind: "followup", value: (item, relatedSales, relatedChannel) => buildOpportunityFollowUpExportText(item, relatedSales, relatedChannel) }, + { key: "salesFollowUps", label: "销售跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "XS_COL") }, + { key: "preSalesFollowUps", label: "售前跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SQ_COL") }, + { key: "afterSalesFollowUps", label: "售后跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SH_COL") }, + { key: "productFollowUps", label: "产品跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "CP_COL") }, + { key: "businessFollowUps", label: "商务跟进记录", kind: "followup", value: (item) => buildOpportunityFollowUpExportTextByRole(item, "SW_COL") }, { key: "salesExpansionName", label: "销售拓展人员姓名", value: (item, relatedSales) => normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name) }, { key: "salesExpansionIntent", label: "销售拓展人员合作意向", value: (item, relatedSales) => normalizeOpportunityExportText(item.salesExpansionIntent || relatedSales?.intent) }, { key: "salesExpansionActive", label: "销售拓展人员是否在职", value: (item, relatedSales) => { @@ -327,20 +346,23 @@ const defaultOpportunityExportFields: OpportunityExportFieldKey[] = [ "competitorName", "latestProgress", "nextPlan", - "followUps", + "salesFollowUps", "isPoc", ]; -function resolveSelectedOpportunityFields(selectedFields: OpportunityExportFieldKey[] | undefined) { - return selectedFields === undefined ? defaultOpportunityExportFields : selectedFields; +function resolveSelectedOpportunityFields(selectedFields: LegacyOpportunityExportFieldKey[] | undefined) { + return selectedFields === undefined ? defaultOpportunityExportFields : normalizeOpportunityExportFieldKeys(selectedFields); } -function buildOpportunityFollowUpExportText( - item: OpportunityItem, - _relatedSales: SalesExpansionItem | null, - _relatedChannel: ChannelExpansionItem | null, -) { +function normalizeOpportunityExportFieldKeys(selectedFields: LegacyOpportunityExportFieldKey[]) { + const validFieldKeys = new Set(opportunityExportColumns.map((column) => column.key)); + const migratedFields = selectedFields.map((field) => field === "followUps" ? "salesFollowUps" : field); + return Array.from(new Set(migratedFields)).filter((field): field is OpportunityExportFieldKey => validFieldKeys.has(field as OpportunityExportFieldKey)); +} + +function buildOpportunityFollowUpExportTextByRole(item: OpportunityItem, roleCode: string) { return (item.followUps ?? []) + .filter((record) => opportunityFollowUpHasRole(record, roleCode)) .map((record) => { const summary = getOpportunityFollowUpSummary(record); const lines = [ @@ -353,6 +375,14 @@ function buildOpportunityFollowUpExportText( .join("\n\n"); } +function opportunityFollowUpHasRole(record: OpportunityFollowUp, roleCode: string) { + const normalizedTarget = roleCode.trim().toUpperCase(); + return (record.roleCodes ?? "") + .split(",") + .map((code) => code.trim().toUpperCase()) + .includes(normalizedTarget); +} + function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) { return normalizeOpportunityExportText(value).toLowerCase(); } @@ -2481,10 +2511,14 @@ export default function Opportunities() { wrapText: rowNumber > 1 && columns[columnNumber - 1]?.kind === "followup", }; }); - const followUpColumnIndex = columns.findIndex((column) => column.kind === "followup") + 1; - if (rowNumber > 1 && followUpColumnIndex > 0) { - const followUpText = normalizeOpportunityExportText(row.getCell(followUpColumnIndex).value as string | null | undefined); - const lineCount = followUpText ? followUpText.split("\n").length : 1; + const followUpColumnIndexes = columns + .map((column, index) => column.kind === "followup" ? index + 1 : 0) + .filter((columnIndex) => columnIndex > 0); + if (rowNumber > 1 && followUpColumnIndexes.length > 0) { + const lineCount = followUpColumnIndexes.reduce((maxLineCount, columnIndex) => { + const followUpText = normalizeOpportunityExportText(row.getCell(columnIndex).value as string | null | undefined); + return Math.max(maxLineCount, followUpText ? followUpText.split("\n").length : 1); + }, 1); row.height = Math.max(22, lineCount * 16); } }); diff --git a/frontend1/dist/index.html b/frontend1/dist/index.html index 3dd8ea4d..d21f14fd 100644 --- a/frontend1/dist/index.html +++ b/frontend1/dist/index.html @@ -5,7 +5,7 @@ UnisBase - 智能会议系统 - + diff --git a/frontend1/src/api/index.ts b/frontend1/src/api/index.ts index 525f8a2a..8d8dfc42 100644 --- a/frontend1/src/api/index.ts +++ b/frontend1/src/api/index.ts @@ -1,7 +1,7 @@ import http from "./http"; import { BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult, - PermissionNode + PermissionNode, UserDataScopeAssignment, UserDataScopeGrant, UserDataScopeUser } from "../types"; export async function pageParams(params: SysParamQuery) { @@ -199,6 +199,31 @@ export async function unbindUserFromRole(roleId: number, userId: number) { return resp.data.data as boolean; } +export async function listUserDataScopeUsers(params?: { tenantId?: number }) { + const resp = await http.get("/sys/api/admin/user-data-scope/users", { params }); + return resp.data.data as UserDataScopeUser[]; +} + +export async function listUserDataScopeGrants(params?: { tenantId?: number; viewerUserId?: number; resourceType?: string }) { + const resp = await http.get("/sys/api/admin/user-data-scope/grants", { params }); + return resp.data.data as UserDataScopeGrant[]; +} + +export async function getUserDataScopeAssignment(params: { tenantId?: number; viewerUserId: number; resourceType: string }) { + const resp = await http.get("/sys/api/admin/user-data-scope/assignment", { params }); + return resp.data.data as UserDataScopeAssignment; +} + +export async function saveUserDataScopeAssignment(payload: UserDataScopeAssignment) { + const resp = await http.put("/sys/api/admin/user-data-scope/assignment", payload); + return resp.data.data as boolean; +} + +export async function deleteUserDataScopeGrant(id: number, params?: { tenantId?: number }) { + const resp = await http.delete(`/sys/api/admin/user-data-scope/grants/${id}`, { params }); + return resp.data.data as boolean; +} + export async function fetchLogs(params: any) { const resp = await http.get("/sys/api/logs", { params }); return resp.data.data; diff --git a/frontend1/src/pages/index.ts b/frontend1/src/pages/index.ts index 820155ed..de2ff80d 100644 --- a/frontend1/src/pages/index.ts +++ b/frontend1/src/pages/index.ts @@ -12,6 +12,7 @@ export { default as SysParams } from "./system/sys-params"; export { default as PlatformSettings } from "./system/platform-settings"; export { default as ReportReminderSettings } from "./system/report-reminder-settings"; export { default as Logs } from "./system/logs"; +export { default as UserDataScope } from "./system/user-data-scope"; export { default as Devices } from "./devices"; export { default as UserRoleBinding } from "./bindings/user-role"; export { default as RolePermissionBinding } from "./bindings/role-permission"; diff --git a/frontend1/src/pages/system/user-data-scope/index.less b/frontend1/src/pages/system/user-data-scope/index.less new file mode 100644 index 00000000..c46bdc24 --- /dev/null +++ b/frontend1/src/pages/system/user-data-scope/index.less @@ -0,0 +1,48 @@ +.user-data-scope-page { + min-height: 100%; +} + +.user-data-scope-overview { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.user-data-scope-stat { + min-height: 72px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + padding: 12px 16px; +} + +.user-data-scope-stat__label { + color: #6b7280; + font-size: 12px; +} + +.user-data-scope-stat__value { + color: #111827; + font-size: 20px; + font-weight: 700; +} + +.user-data-scope-filter, +.user-data-scope-table-card { + border-radius: 8px !important; +} + +.user-data-scope-table-card { + overflow: hidden; +} + +@media (max-width: 991px) { + .user-data-scope-overview { + grid-template-columns: 1fr; + } +} diff --git a/frontend1/src/pages/system/user-data-scope/index.tsx b/frontend1/src/pages/system/user-data-scope/index.tsx new file mode 100644 index 00000000..bd43dcfa --- /dev/null +++ b/frontend1/src/pages/system/user-data-scope/index.tsx @@ -0,0 +1,540 @@ +import { App, Button, Card, Col, DatePicker, Drawer, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import dayjs from "dayjs"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from "@ant-design/icons"; +import PageHeader from "@/components/shared/PageHeader"; +import { deleteUserDataScopeGrant, listTenants, listUserDataScopeGrants, listUserDataScopeUsers, saveUserDataScopeAssignment } from "@/api"; +import { usePermission } from "@/hooks/usePermission"; +import { getStandardPagination } from "@/utils/pagination"; +import type { SysTenant, UserDataScopeGrant, UserDataScopeUser } from "@/types"; +import "./index.less"; + +const { Text } = Typography; + +const resourceOptions = [ + { label: "商机数据", value: "OPPORTUNITY" }, + { label: "拓展数据", value: "EXPANSION" }, + { label: "日报数据", value: "DAILY_REPORT" }, + { label: "打卡数据", value: "CHECKIN" } +]; + +const resourceLabels: Record = { + ALL: "全部数据", + OPPORTUNITY: "商机数据", + EXPANSION: "拓展数据", + DAILY_REPORT: "日报数据", + CHECKIN: "打卡数据", + CUSTOMER: "客户数据", + WORK: "工作数据" +}; + +interface UserDataScopeGrantGroup { + id: string; + grantIds: number[]; + tenantId: number; + viewerUserId: number; + viewerName: string; + ownerUsers: Array<{ userId: number; name: string }>; + resourceTypes: string[]; + enabled: boolean; + expireAt?: string; + remark?: string; + grants: UserDataScopeGrant[]; +} + +function getActiveTenantId() { + const raw = localStorage.getItem("activeTenantId"); + const value = raw ? Number(raw) : 0; + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +function isPlatformMode() { + try { + const profileStr = sessionStorage.getItem("userProfile"); + const profile = profileStr ? JSON.parse(profileStr) : {}; + return Boolean(profile.isPlatformAdmin && Number(localStorage.getItem("activeTenantId") || profile.tenantId || 0) === 0); + } catch { + return false; + } +} + +function userLabel(user: UserDataScopeUser) { + const name = user.displayName || user.username || String(user.userId); + return `${name}${user.username && user.username !== name ? ` (${user.username})` : ""}`; +} + +function resourceLabel(value?: string) { + return value ? resourceLabels[value] || value : "-"; +} + +function isGroupExpired(group: UserDataScopeGrantGroup) { + return Boolean(group.expireAt && new Date(group.expireAt).getTime() <= Date.now()); +} + +function formatExpireAt(value?: string) { + return value ? value.replace("T", " ").slice(0, 16) : "永久"; +} + +function buildGrantGroups(source: UserDataScopeGrant[]) { + const buckets = new Map>(); + for (const grant of source) { + const bucketKey = [ + grant.tenantId, + grant.viewerUserId, + grant.enabled ? "1" : "0", + grant.expireAt || "", + grant.remark || "" + ].join("|"); + const resourceMap = buckets.get(bucketKey) || new Map(); + const resourceGrants = resourceMap.get(grant.resourceType) || []; + resourceGrants.push(grant); + resourceMap.set(grant.resourceType, resourceGrants); + buckets.set(bucketKey, resourceMap); + } + + const groups: UserDataScopeGrantGroup[] = []; + for (const [bucketKey, resourceMap] of buckets) { + const ownerSetMap = new Map(); + for (const resourceGrants of resourceMap.values()) { + const ownerKey = resourceGrants + .map((grant) => grant.ownerUserId) + .sort((a, b) => a - b) + .join(","); + ownerSetMap.set(ownerKey, [...(ownerSetMap.get(ownerKey) || []), ...resourceGrants]); + } + + for (const [ownerKey, groupGrants] of ownerSetMap) { + const first = groupGrants[0]; + const ownerUsers = Array.from( + new Map(groupGrants.map((grant) => [grant.ownerUserId, { userId: grant.ownerUserId, name: grant.ownerName }])).values() + ).sort((a, b) => a.name.localeCompare(b.name, "zh-CN")); + const resourceTypes = Array.from(new Set(groupGrants.map((grant) => grant.resourceType))) + .sort((a, b) => resourceLabel(a).localeCompare(resourceLabel(b), "zh-CN")); + + groups.push({ + id: `${bucketKey}|${ownerKey}`, + grantIds: groupGrants.map((grant) => grant.id), + tenantId: first.tenantId, + viewerUserId: first.viewerUserId, + viewerName: first.viewerName, + ownerUsers, + resourceTypes, + enabled: first.enabled, + expireAt: first.expireAt, + remark: first.remark, + grants: groupGrants + }); + } + } + + return groups.sort((a, b) => ( + a.viewerName.localeCompare(b.viewerName, "zh-CN") + || a.resourceTypes.join(",").localeCompare(b.resourceTypes.join(","), "zh-CN") + || a.ownerUsers.map((owner) => owner.name).join(",").localeCompare(b.ownerUsers.map((owner) => owner.name).join(","), "zh-CN") + )); +} + +export default function UserDataScopePage() { + const { message } = App.useApp(); + const { can } = usePermission(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [users, setUsers] = useState([]); + const [grants, setGrants] = useState([]); + const [tenants, setTenants] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editingGrantGroup, setEditingGrantGroup] = useState(null); + const [selectedTenantId, setSelectedTenantId] = useState(() => getActiveTenantId()); + const [query, setQuery] = useState<{ keyword?: string; viewerUserId?: number; resourceType?: string; current: number; pageSize: number }>({ + current: 1, + pageSize: 10 + }); + const platformMode = useMemo(() => isPlatformMode(), []); + + const loadTenants = useCallback(async () => { + if (!platformMode) { + return; + } + const response = await listTenants({ current: 1, size: 1000 }); + setTenants(response?.records || []); + }, [platformMode]); + + const loadUsers = useCallback(async () => { + if (!selectedTenantId) { + setUsers([]); + return; + } + const list = await listUserDataScopeUsers({ tenantId: selectedTenantId }); + setUsers(list || []); + }, [selectedTenantId]); + + const loadGrants = useCallback(async () => { + if (!selectedTenantId) { + setGrants([]); + return; + } + const list = await listUserDataScopeGrants({ tenantId: selectedTenantId }); + setGrants(list || []); + }, [selectedTenantId]); + + const loadPageData = useCallback(async () => { + setLoading(true); + try { + await Promise.all([loadTenants(), loadUsers(), loadGrants()]); + } finally { + setLoading(false); + } + }, [loadTenants, loadUsers, loadGrants]); + + useEffect(() => { + loadPageData(); + }, [loadPageData]); + + const userOptions = useMemo(() => users.map((user) => ({ + label: ( + + {userLabel(user)} + {user.orgName && {user.orgName}} + + ), + displayLabel: userLabel(user), + value: user.userId, + searchText: `${user.displayName || ""} ${user.username || ""} ${user.orgName || ""}` + })), [users]); + + const grantGroups = useMemo(() => buildGrantGroups(grants), [grants]); + + const filteredGrantGroups = useMemo(() => { + const normalizedKeyword = (query.keyword || "").trim().toLowerCase(); + return grantGroups.filter((group) => { + if (query.viewerUserId && group.viewerUserId !== query.viewerUserId) { + return false; + } + if (query.resourceType && !group.resourceTypes.includes(query.resourceType)) { + return false; + } + if (!normalizedKeyword) { + return true; + } + return [ + group.viewerName, + group.remark, + String(group.viewerUserId), + ...group.ownerUsers.flatMap((owner) => [owner.name, String(owner.userId)]), + ...group.resourceTypes.flatMap((type) => [type, resourceLabel(type)]) + ].filter(Boolean).join(" ").toLowerCase().includes(normalizedKeyword); + }); + }, [grantGroups, query.keyword, query.resourceType, query.viewerUserId]); + + const pagedGrantGroups = useMemo(() => { + const start = (query.current - 1) * query.pageSize; + return filteredGrantGroups.slice(start, start + query.pageSize); + }, [filteredGrantGroups, query.current, query.pageSize]); + + const activeGrantCount = useMemo( + () => grantGroups.filter((group) => group.enabled && !isGroupExpired(group)).length, + [grantGroups] + ); + + const selectedDrawerViewerId = Form.useWatch("viewerUserId", form); + const ownerOptions = useMemo( + () => userOptions.filter((option) => option.value !== selectedDrawerViewerId), + [selectedDrawerViewerId, userOptions] + ); + + const openCreate = () => { + setEditingGrantGroup(null); + form.resetFields(); + form.setFieldsValue({ + resourceTypes: ["OPPORTUNITY"], + enabled: true, + ownerUserIds: [] + }); + setDrawerOpen(true); + }; + + const openEdit = (record: UserDataScopeGrantGroup) => { + setEditingGrantGroup(record); + form.resetFields(); + form.setFieldsValue({ + viewerUserId: record.viewerUserId, + resourceTypes: record.resourceTypes, + ownerUserIds: record.ownerUsers.map((owner) => owner.userId), + enabled: record.enabled, + expireAt: record.expireAt ? dayjs(record.expireAt) : undefined, + remark: record.remark + }); + setDrawerOpen(true); + }; + + const closeDrawer = () => { + setDrawerOpen(false); + setEditingGrantGroup(null); + }; + + const handleSave = async () => { + if (!selectedTenantId) { + message.warning("请先选择租户"); + return; + } + const values = await form.validateFields(); + const resourceTypes = values.resourceTypes || []; + setSaving(true); + try { + if (editingGrantGroup) { + const selectedResourceTypes = new Set(resourceTypes); + const removedGrantIds = editingGrantGroup.grants + .filter((grant) => !selectedResourceTypes.has(grant.resourceType)) + .map((grant) => grant.id); + await Promise.all(removedGrantIds.map((id) => deleteUserDataScopeGrant(id, { tenantId: selectedTenantId }))); + } + await saveUserDataScopeAssignment({ + tenantId: selectedTenantId, + viewerUserId: values.viewerUserId, + resourceType: resourceTypes[0], + resourceTypes, + ownerUserIds: values.ownerUserIds || [], + enabled: values.enabled !== false, + expireAt: values.expireAt ? values.expireAt.toISOString() : undefined, + remark: values.remark + }); + message.success("数据授权已保存"); + closeDrawer(); + await loadGrants(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (record: UserDataScopeGrantGroup) => { + await Promise.all(record.grantIds.map((id) => deleteUserDataScopeGrant(id, { tenantId: selectedTenantId }))); + message.success("数据授权已删除"); + await loadGrants(); + }; + + const columns: ColumnsType = [ + { + title: "查看人", + dataIndex: "viewerName", + width: 180, + render: (value: string, record) => ( + + {value} + ID: {record.viewerUserId} + + ) + }, + { + title: "可见人员", + dataIndex: "ownerUsers", + width: 260, + render: (_: unknown, record) => ( + + {record.ownerUsers.map((owner) => ( + + {owner.name} + ID: {owner.userId} + + ))} + + ) + }, + { + title: "资源范围", + dataIndex: "resourceTypes", + width: 220, + render: (_: unknown, record) => ( + + {record.resourceTypes.map((value) => ( + {resourceLabel(value)} + ))} + + ) + }, + { + title: "状态", + dataIndex: "enabled", + width: 100, + render: (enabled: boolean, record) => { + if (isGroupExpired(record)) { + return 已过期; + } + return {enabled ? "启用" : "停用"}; + } + }, + { + title: "有效期", + dataIndex: "expireAt", + width: 170, + render: (value?: string) => formatExpireAt(value) + }, + { + title: "备注", + dataIndex: "remark", + ellipsis: true, + render: (value?: string) => value ? {value} : "-" + }, + { + title: "操作", + key: "action", + width: 120, + fixed: "right", + render: (_: unknown, record) => ( + + + + + + + + setQuery((prev) => ({ ...prev, current: page, pageSize })) + )} + /> + + + {editingGrantGroup ? "编辑数据授权" : "新建数据授权"}} + open={drawerOpen} + onClose={closeDrawer} + width={560} + destroyOnHidden + footer={
} + > +
+ + + + + + + +
+ + + + + + + + + + + + ); +} diff --git a/frontend1/src/routes/routes.tsx b/frontend1/src/routes/routes.tsx index 28c6b762..3c2a0d97 100644 --- a/frontend1/src/routes/routes.tsx +++ b/frontend1/src/routes/routes.tsx @@ -13,6 +13,7 @@ const SysParams = lazy(() => import("@/pages/system/sys-params")); const PlatformSettings = lazy(() => import("@/pages/system/platform-settings")); const Dictionaries = lazy(() => import("@/pages/system/dictionaries")); const Logs = lazy(() => import("@/pages/system/logs")); +const UserDataScope = lazy(() => import("@/pages/system/user-data-scope")); const ReportReminderSettings = lazy(() => import("@/features/report-reminder/pages/report-reminder-settings")); const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics/pages/dashboard-analytics-settings")); const OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer")); @@ -44,6 +45,7 @@ export const menuRoutes: MenuRoute[] = [ { path: "/platform-settings", label: "平台设置", element: , perm: "menu:platform" }, { path: "/dictionaries", label: "字典管理", element: , perm: "menu:dict" }, { path: "/logs", label: "日志管理", element: , perm: "menu:logs" }, + { path: "/data-scope-users", label: "数据授权管理", element: , perm: "menu:data-scope-users" }, { path: "/dashboard-analytics-settings", label: "首页经营分析配置", element: , perm: "menu:dashboard-analytics-settings" }, { path: "/report-reminder-settings", label: "日报提醒设置", element: , perm: "menu:report-reminder-settings" }, { path: "/owner-transfer", label: "归属人转移", element: , perm: "menu:owner-transfer" }, diff --git a/frontend1/src/types/index.ts b/frontend1/src/types/index.ts index 25611f8b..0a226d47 100644 --- a/frontend1/src/types/index.ts +++ b/frontend1/src/types/index.ts @@ -121,6 +121,41 @@ export interface SysOrg extends BaseEntity { sortOrder?: number; } +export interface UserDataScopeUser { + userId: number; + username: string; + displayName: string; + orgName?: string; + status?: number; + tenantId?: number; +} + +export interface UserDataScopeGrant { + id: number; + tenantId: number; + viewerUserId: number; + viewerName: string; + ownerUserId: number; + ownerName: string; + resourceType: string; + enabled: boolean; + expireAt?: string; + remark?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface UserDataScopeAssignment { + tenantId?: number; + viewerUserId: number; + resourceType?: string; + resourceTypes?: string[]; + ownerUserIds: number[]; + expireAt?: string; + remark?: string; + enabled?: boolean; +} + export interface SysLog { id: number; tenantId?: number; diff --git a/sql/20260701_opportunity_updated_by_pg17.sql b/sql/20260701_opportunity_updated_by_pg17.sql new file mode 100644 index 00000000..014ae52b --- /dev/null +++ b/sql/20260701_opportunity_updated_by_pg17.sql @@ -0,0 +1,4 @@ +alter table if exists crm_opportunity + add column if not exists updated_by bigint; + +comment on column crm_opportunity.updated_by is '更新人ID'; diff --git a/sql/20260701_user_data_scope_user_pg17.sql b/sql/20260701_user_data_scope_user_pg17.sql new file mode 100644 index 00000000..8af03f73 --- /dev/null +++ b/sql/20260701_user_data_scope_user_pg17.sql @@ -0,0 +1,284 @@ +create table if not exists sys_user_data_scope_user ( + id bigserial primary key, + tenant_id bigint not null, + viewer_user_id bigint not null, + owner_user_id bigint not null, + resource_type varchar(50) not null default 'ALL', + enabled boolean not null default true, + expire_at timestamptz, + remark varchar(500), + created_by bigint, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + is_deleted integer not null default 0 +); + +create unique index if not exists uk_user_data_scope_user + on sys_user_data_scope_user (tenant_id, viewer_user_id, owner_user_id, resource_type) + where is_deleted = 0; + +create index if not exists idx_user_data_scope_viewer + on sys_user_data_scope_user (tenant_id, viewer_user_id, resource_type, enabled); + +create index if not exists idx_user_data_scope_owner + on sys_user_data_scope_user (tenant_id, owner_user_id); + +comment on table sys_user_data_scope_user is '用户到用户的数据可见范围授权'; +comment on column sys_user_data_scope_user.viewer_user_id is '被授权查看数据的用户ID'; +comment on column sys_user_data_scope_user.owner_user_id is '数据归属用户ID'; +comment on column sys_user_data_scope_user.resource_type is '授权资源类型:OPPORTUNITY/EXPANSION/DAILY_REPORT/CHECKIN等'; + +do $$ +declare + v_system_parent_id bigint; + v_menu_perm_id bigint; + v_view_perm_id bigint; + v_update_perm_id bigint; + v_has_role_permission_tenant boolean; +begin + perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false); + perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false); + + select exists ( + select 1 + from information_schema.columns + where table_schema = current_schema() + and table_name = 'sys_role_permission' + and column_name = 'tenant_id' + ) into v_has_role_permission_tenant; + + select perm_id + into v_system_parent_id + from sys_permission + where code = 'system' + and coalesce(is_deleted, 0) = 0 + order by perm_id + limit 1; + + if v_system_parent_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + null, '系统管理', 'system', 'directory', 1, null, null, 'SettingOutlined', + 110, 1, 1, '系统管理目录', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_system_parent_id; + end if; + + select perm_id + into v_menu_perm_id + from sys_permission + where code = 'menu:data-scope-users' + order by perm_id + limit 1; + + if v_menu_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_system_parent_id, '数据授权管理', 'menu:data-scope-users', 'menu', 2, + '/data-scope-users', null, 'TeamOutlined', 8, 1, 1, + '配置用户可额外查看哪些人员名下的数据', jsonb_build_object('tenantScoped', true), 0, now(), now() + ) + returning perm_id into v_menu_perm_id; + else + update sys_permission + set parent_id = v_system_parent_id, + name = '数据授权管理', + perm_type = 'menu', + level = 2, + path = '/data-scope-users', + component = null, + icon = 'TeamOutlined', + sort_order = 8, + is_visible = 1, + status = 1, + description = '配置用户可额外查看哪些人员名下的数据', + meta = jsonb_build_object('tenantScoped', true), + is_deleted = 0, + updated_at = now() + where perm_id = v_menu_perm_id; + end if; + + select perm_id + into v_view_perm_id + from sys_permission + where code = 'user_data_scope:view' + order by perm_id + limit 1; + + if v_view_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_menu_perm_id, '查看数据授权', 'user_data_scope:view', 'button', 3, + null, null, null, 1, 1, 1, '查看用户数据授权配置', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_view_perm_id; + else + update sys_permission + set parent_id = v_menu_perm_id, + name = '查看数据授权', + perm_type = 'button', + level = 3, + sort_order = 1, + is_visible = 1, + status = 1, + description = '查看用户数据授权配置', + meta = '{}'::jsonb, + is_deleted = 0, + updated_at = now() + where perm_id = v_view_perm_id; + end if; + + select perm_id + into v_update_perm_id + from sys_permission + where code = 'user_data_scope:update' + order by perm_id + limit 1; + + if v_update_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_menu_perm_id, '修改数据授权', 'user_data_scope:update', 'button', 3, + null, null, null, 2, 1, 1, '保存用户数据授权配置', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_update_perm_id; + else + update sys_permission + set parent_id = v_menu_perm_id, + name = '修改数据授权', + perm_type = 'button', + level = 3, + sort_order = 2, + is_visible = 1, + status = 1, + description = '保存用户数据授权配置', + meta = '{}'::jsonb, + is_deleted = 0, + updated_at = now() + where perm_id = v_update_perm_id; + end if; + + if v_has_role_permission_tenant then + insert into sys_role_permission (role_id, perm_id, tenant_id, is_deleted, created_at, updated_at) + select role_source.role_id, perm_source.perm_id, role_source.tenant_id, 0, now(), now() + from ( + select distinct role_id, tenant_id + from ( + select r.role_id, r.tenant_id + from sys_role r + where coalesce(r.is_deleted, 0) = 0 + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ) + union + select r.role_id, r.tenant_id + from sys_user u + join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0 + join sys_role r on r.role_id = ur.role_id and coalesce(r.is_deleted, 0) = 0 + where coalesce(u.is_deleted, 0) = 0 + and u.username = 'admin' + ) granted_roles + ) role_source + cross join ( + select unnest(array[v_menu_perm_id, v_view_perm_id, v_update_perm_id]) as perm_id + ) perm_source + where perm_source.perm_id is not null + and not exists ( + select 1 + from sys_role_permission rp + where rp.role_id = role_source.role_id + and rp.perm_id = perm_source.perm_id + ); + + update sys_role_permission rp + set tenant_id = coalesce(rp.tenant_id, r.tenant_id), + is_deleted = 0, + updated_at = now() + from sys_role r, + sys_permission p + where rp.role_id = r.role_id + and p.perm_id = rp.perm_id + and coalesce(r.is_deleted, 0) = 0 + and p.code in ('menu:data-scope-users', 'user_data_scope:view', 'user_data_scope:update') + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + or exists ( + select 1 + from sys_user u + join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0 + where coalesce(u.is_deleted, 0) = 0 + and u.username = 'admin' + and ur.role_id = r.role_id + ) + ); + else + insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at) + select role_source.role_id, perm_source.perm_id, 0, now(), now() + from ( + select distinct role_id + from ( + select r.role_id + from sys_role r + where coalesce(r.is_deleted, 0) = 0 + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ) + union + select r.role_id + from sys_user u + join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0 + join sys_role r on r.role_id = ur.role_id and coalesce(r.is_deleted, 0) = 0 + where coalesce(u.is_deleted, 0) = 0 + and u.username = 'admin' + ) granted_roles + ) role_source + cross join ( + select unnest(array[v_menu_perm_id, v_view_perm_id, v_update_perm_id]) as perm_id + ) perm_source + where perm_source.perm_id is not null + and not exists ( + select 1 + from sys_role_permission rp + where rp.role_id = role_source.role_id + and rp.perm_id = perm_source.perm_id + ); + + update sys_role_permission rp + set is_deleted = 0, + updated_at = now() + from sys_role r, + sys_permission p + where rp.role_id = r.role_id + and p.perm_id = rp.perm_id + and coalesce(r.is_deleted, 0) = 0 + and p.code in ('menu:data-scope-users', 'user_data_scope:view', 'user_data_scope:update') + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + or exists ( + select 1 + from sys_user u + join sys_user_role ur on ur.user_id = u.user_id and coalesce(ur.is_deleted, 0) = 0 + where coalesce(u.is_deleted, 0) = 0 + and u.username = 'admin' + and ur.role_id = r.role_id + ) + ); + end if; +end +$$; diff --git a/sql/20260701_work_report_button_permissions_pg17.sql b/sql/20260701_work_report_button_permissions_pg17.sql new file mode 100644 index 00000000..4c33d782 --- /dev/null +++ b/sql/20260701_work_report_button_permissions_pg17.sql @@ -0,0 +1,242 @@ +-- 20260701 +-- 新增工作日报按钮权限点: +-- daily_report:notify_users 选择通知用户 +-- daily_report:attachments 日报附件 +-- 执行后可在 frontend1 权限管理与角色权限绑定中维护这些权限。 +-- 可重复执行;CRM业务与工作节点仅用于权限管理,不在后台侧边栏显示。 +-- “# 关联对象是否必填”继续由 application.yml 的 work-report.daily-report 配置控制。 + +begin; + +set search_path to public; + +do $$ +declare + v_business_parent_id bigint; + v_work_menu_perm_id bigint; + v_notify_users_perm_id bigint; + v_attachments_perm_id bigint; + v_has_role_permission_tenant boolean; +begin + perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false); + perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false); + + select exists ( + select 1 + from information_schema.columns + where table_schema = current_schema() + and table_name = 'sys_role_permission' + and column_name = 'tenant_id' + ) into v_has_role_permission_tenant; + + select perm_id + into v_business_parent_id + from sys_permission + where code = 'crm' + order by perm_id + limit 1; + + if v_business_parent_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + null, 'CRM业务', 'crm', 'directory', 1, null, null, 'AppstoreOutlined', + 10, 0, 1, 'CRM前台业务权限分组,仅用于权限管理,不在后台侧边栏显示', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_business_parent_id; + else + update sys_permission + set name = 'CRM业务', + perm_type = 'directory', + level = 1, + path = null, + component = null, + icon = coalesce(nullif(icon, ''), 'AppstoreOutlined'), + sort_order = coalesce(sort_order, 10), + is_visible = 0, + status = 1, + description = 'CRM前台业务权限分组,仅用于权限管理,不在后台侧边栏显示', + meta = coalesce(meta, '{}'::jsonb), + is_deleted = 0, + updated_at = now() + where perm_id = v_business_parent_id; + end if; + + select perm_id + into v_work_menu_perm_id + from sys_permission + where code = 'menu:work' + order by perm_id + limit 1; + + if v_work_menu_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_business_parent_id, '工作', 'menu:work', 'menu', 2, + null, null, 'ScheduleOutlined', 40, 0, 1, + 'CRM前台工作台与日报权限分组,仅用于权限管理,不在后台侧边栏显示', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_work_menu_perm_id; + else + update sys_permission + set parent_id = v_business_parent_id, + name = '工作', + perm_type = 'menu', + level = 2, + path = null, + component = null, + icon = coalesce(nullif(icon, ''), 'ScheduleOutlined'), + sort_order = coalesce(sort_order, 40), + is_visible = 0, + status = 1, + description = 'CRM前台工作台与日报权限分组,仅用于权限管理,不在后台侧边栏显示', + meta = coalesce(meta, '{}'::jsonb), + is_deleted = 0, + updated_at = now() + where perm_id = v_work_menu_perm_id; + end if; + + select perm_id + into v_notify_users_perm_id + from sys_permission + where code = 'daily_report:notify_users' + order by perm_id + limit 1; + + if v_notify_users_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_work_menu_perm_id, '选择通知用户', 'daily_report:notify_users', 'button', 3, + null, null, null, 1, 1, 1, '控制CRM前台日报中的选择通知用户按钮和用户选择接口', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_notify_users_perm_id; + else + update sys_permission + set parent_id = v_work_menu_perm_id, + name = '选择通知用户', + perm_type = 'button', + level = 3, + path = null, + component = null, + icon = null, + sort_order = 1, + is_visible = 1, + status = 1, + description = '控制CRM前台日报中的选择通知用户按钮和用户选择接口', + meta = '{}'::jsonb, + is_deleted = 0, + updated_at = now() + where perm_id = v_notify_users_perm_id; + end if; + + select perm_id + into v_attachments_perm_id + from sys_permission + where code = 'daily_report:attachments' + order by perm_id + limit 1; + + if v_attachments_perm_id is null then + insert into sys_permission ( + parent_id, name, code, perm_type, level, path, component, icon, + sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at + ) values ( + v_work_menu_perm_id, '日报附件', 'daily_report:attachments', 'button', 3, + null, null, null, 2, 1, 1, '控制CRM前台日报附件按钮、附件上传接口和日报附件保存', '{}'::jsonb, 0, now(), now() + ) + returning perm_id into v_attachments_perm_id; + else + update sys_permission + set parent_id = v_work_menu_perm_id, + name = '日报附件', + perm_type = 'button', + level = 3, + path = null, + component = null, + icon = null, + sort_order = 2, + is_visible = 1, + status = 1, + description = '控制CRM前台日报附件按钮、附件上传接口和日报附件保存', + meta = '{}'::jsonb, + is_deleted = 0, + updated_at = now() + where perm_id = v_attachments_perm_id; + end if; + + if v_has_role_permission_tenant then + insert into sys_role_permission (role_id, perm_id, tenant_id, is_deleted, created_at, updated_at) + select distinct r.role_id, permission_source.perm_id, r.tenant_id, 0, now(), now() + from sys_role r + cross join ( + select unnest(array[v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id]) as perm_id + ) permission_source + where coalesce(r.is_deleted, 0) = 0 + and permission_source.perm_id is not null + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ) + and not exists ( + select 1 + from sys_role_permission rp + where rp.role_id = r.role_id + and rp.perm_id = permission_source.perm_id + ); + + update sys_role_permission rp + set tenant_id = coalesce(rp.tenant_id, r.tenant_id), + is_deleted = 0, + updated_at = now() + from sys_role r + where rp.role_id = r.role_id + and rp.perm_id in (v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id) + and coalesce(r.is_deleted, 0) = 0 + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ); + else + insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at) + select distinct r.role_id, permission_source.perm_id, 0, now(), now() + from sys_role r + cross join ( + select unnest(array[v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id]) as perm_id + ) permission_source + where coalesce(r.is_deleted, 0) = 0 + and permission_source.perm_id is not null + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ) + and not exists ( + select 1 + from sys_role_permission rp + where rp.role_id = r.role_id + and rp.perm_id = permission_source.perm_id + ); + + update sys_role_permission rp + set is_deleted = 0, + updated_at = now() + from sys_role r + where rp.role_id = r.role_id + and rp.perm_id in (v_business_parent_id, v_work_menu_perm_id, v_notify_users_perm_id, v_attachments_perm_id) + and coalesce(r.is_deleted, 0) = 0 + and ( + r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN') + or r.role_name ilike '%管理员%' + or r.role_name ilike '%admin%' + ); + end if; +end $$; + +commit; diff --git a/sql/init_full_pg17.sql b/sql/init_full_pg17.sql index 043e207a..c2edd31d 100644 --- a/sql/init_full_pg17.sql +++ b/sql/init_full_pg17.sql @@ -141,6 +141,7 @@ create table if not exists crm_opportunity ( competitor_name varchar(200), latest_progress text, next_plan text, + updated_by bigint, archived boolean not null default false, archived_at timestamptz, pushed_to_oms boolean not null default false, @@ -861,6 +862,7 @@ WITH column_comments(table_name, column_name, comment_text) AS ( ('crm_opportunity', 'competitor_name', '竞品名称'), ('crm_opportunity', 'latest_progress', '项目最新进展'), ('crm_opportunity', 'next_plan', '下一步销售计划'), + ('crm_opportunity', 'updated_by', '更新人ID'), ('crm_opportunity', 'archived', '是否归档'), ('crm_opportunity', 'archived_at', '归档时间'), ('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),