1、导出时分列。

2、商机更新时间只有销售修改数据时才会改变。
3、数据权限要给到人。
main
kangwenjing 2026-07-01 16:31:11 +08:00
parent c6b695169e
commit aa632ec6ec
41 changed files with 3654 additions and 182 deletions

View File

@ -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测试项目'");

View File

@ -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);
}
}
}

View File

@ -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<List<UserDataScopeUserDTO>> listUsers(
@RequestParam(value = "tenantId", required = false) Long tenantId) {
return ApiResponse.success(userDataScopeAdminService.listUsers(tenantId));
}
@GetMapping("/grants")
public ApiResponse<List<UserDataScopeGrantDTO>> 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<UserDataScopeAssignmentDTO> 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<Boolean> saveAssignment(@RequestBody UserDataScopeAssignmentDTO payload) {
return ApiResponse.success(userDataScopeAdminService.saveAssignment(payload));
}
@DeleteMapping("/grants/{id}")
@Log(type = "系统管理", value = "删除用户数据授权")
public ApiResponse<Boolean> deleteGrant(
@PathVariable("id") Long id,
@RequestParam(value = "tenantId", required = false) Long tenantId) {
return ApiResponse.success(userDataScopeAdminService.deleteGrant(tenantId, id));
}
}

View File

@ -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<String> resourceTypes = new ArrayList<>();
private List<Long> 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<String> getResourceTypes() {
return resourceTypes;
}
public void setResourceTypes(List<String> resourceTypes) {
this.resourceTypes = resourceTypes;
}
public List<Long> getOwnerUserIds() {
return ownerUserIds;
}
public void setOwnerUserIds(List<Long> 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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -16,6 +16,7 @@ public class OpportunityFollowUpDTO {
private String communicationContent;
private String nextAction;
private String user;
private String roleCodes;
private List<WorkReportAttachmentDTO> 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<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}

View File

@ -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<String> 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<String> getPhotoUrls() {
return photoUrls;
}

View File

@ -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<WorkReportLineItemDTO> lineItems = new ArrayList<>();
private List<WorkReportAttachmentDTO> attachments = new ArrayList<>();
private List<WorkTomorrowPlanItemDTO> 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<WorkReportLineItemDTO> getLineItems() {
return lineItems;
}

View File

@ -32,9 +32,17 @@ public interface ExpansionMapper {
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
List<SalesExpansionItemDTO> selectSalesExpansionsByOwnerUserIds(
@Param("ownerUserIds") List<Long> ownerUserIds,
@Param("keyword") String keyword);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
List<ChannelExpansionItemDTO> selectChannelExpansionsByOwnerUserIds(
@Param("ownerUserIds") List<Long> ownerUserIds,
@Param("keyword") String keyword);
List<SalesExpansionItemDTO> 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<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<RelatedProjectSummaryDTO> selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ExpansionFollowUpDTO> selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionContactDTO> selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<ChannelRelatedProjectSummaryDTO> selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request);

View File

@ -32,11 +32,23 @@ public interface WorkMapper {
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectCheckInHistory(@Param("limit") int limit);
List<WorkHistoryItemDTO> selectCheckInHistoryByUserIds(
@Param("visibleUserIds") List<Long> visibleUserIds,
@Param("limit") int limit);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
List<WorkHistoryItemDTO> selectReportHistoryByUserIds(
@Param("visibleUserIds") List<Long> visibleUserIds,
@Param("limit") int limit);
WorkHistoryItemDTO selectReportHistoryById(@Param("userId") Long userId, @Param("reportId") Long reportId);
WorkHistoryItemDTO selectReportHistoryByIdForUserIds(
@Param("reportId") Long reportId,
@Param("visibleUserIds") List<Long> visibleUserIds);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkCheckInExportDTO> selectCheckInExportRows(
@Param("startDate") LocalDate startDate,
@ -47,6 +59,16 @@ public interface WorkMapper {
@Param("status") String status,
@Param("limit") int limit);
List<WorkCheckInExportDTO> selectCheckInExportRowsByUserIds(
@Param("visibleUserIds") List<Long> 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<WorkDailyReportExportDTO> selectDailyReportExportRows(
@Param("startDate") LocalDate startDate,
@ -56,6 +78,15 @@ public interface WorkMapper {
@Param("status") String status,
@Param("limit") int limit);
List<WorkDailyReportExportDTO> selectDailyReportExportRowsByUserIds(
@Param("visibleUserIds") List<Long> 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);

View File

@ -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<Long> visibleOwnerUserIds) {
}
}

View File

@ -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<String> 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<UserDataScopeUserDTO> 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<UserDataScopeGrantDTO> listGrants(Long tenantId, Long viewerUserId, String resourceType) {
Long resolvedTenantId = resolveTenantId(tenantId);
requirePermission(VIEW_PERM, "无权查看数据授权配置");
String normalizedResourceType = normalizeResourceType(resourceType, false);
List<Object> 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<UserDataScopeGrantDTO> 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<String> resourceTypes = normalizeResourceTypes(payload.getResourceTypes(), payload.getResourceType());
requireUserInTenant(tenantId, viewerUserId, "查看人不存在或不属于当前租户");
List<Long> 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<Long> ownerUserIds) {
for (Long ownerUserId : ownerUserIds) {
requireUserInTenant(tenantId, ownerUserId, "可见人员不存在或不属于当前租户");
}
}
private List<Long> normalizeOwnerUserIds(List<Long> ownerUserIds, Long viewerUserId) {
if (ownerUserIds == null || ownerUserIds.isEmpty()) {
return List.of();
}
Set<Long> 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<String> normalizeResourceTypes(List<String> resourceTypes, String fallbackResourceType) {
Set<String> 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();
}
}

View File

@ -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<Long> 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<Long> 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<Long> target, List<Long> 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);
}
}

View File

@ -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<SalesExpansionItemDTO> salesItems = expansionMapper.selectSalesExpansions(userId, normalizedKeyword);
List<ChannelExpansionItemDTO> channelItems = expansionMapper.selectChannelExpansions(userId, normalizedKeyword);
List<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
List<SalesExpansionItemDTO> salesItems = mergeSalesExpansionItems(
expansionMapper.selectSalesExpansions(userId, normalizedKeyword),
extraVisibleOwnerUserIds.isEmpty()
? List.of()
: expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword));
List<ChannelExpansionItemDTO> 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<Long> 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<Long> 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<SalesExpansionItemDTO> mergeSalesExpansionItems(
List<SalesExpansionItemDTO> scopedItems,
List<SalesExpansionItemDTO> extraItems) {
Map<Long, SalesExpansionItemDTO> 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<ChannelExpansionItemDTO> mergeChannelExpansionItems(
List<ChannelExpansionItemDTO> scopedItems,
List<ChannelExpansionItemDTO> extraItems) {
Map<Long, ChannelExpansionItemDTO> 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) {

View File

@ -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<String> 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<String> resolveCurrentUserPreSalesNames(Long userId) {

View File

@ -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<WorkReportToUserDTO> 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<String> 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<WorkHistoryItemDTO> historyItems = loadHistoryItems(historyType, fetchLimit);
List<WorkHistoryItemDTO> 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<Long> 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<WorkCheckInExportDTO> rows = workMapper.selectCheckInExportRows(
startDate,
endDate,
normalizeOptionalText(keyword),
normalizeOptionalText(deptName),
normalizedBizType,
normalizeOptionalText(status),
EXPORT_LIMIT);
List<Long> extraVisibleUserIds = resolveExtraVisibleUserIds(
userId,
CrmDataVisibilityService.RESOURCE_CHECKIN);
List<WorkCheckInExportDTO> 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<WorkDailyReportExportDTO> rows = workMapper.selectDailyReportExportRows(
startDate,
endDate,
normalizeOptionalText(keyword),
normalizeOptionalText(deptName),
normalizeOptionalText(status),
EXPORT_LIMIT);
List<Long> extraVisibleUserIds = resolveExtraVisibleUserIds(
userId,
CrmDataVisibilityService.RESOURCE_DAILY_REPORT);
List<WorkDailyReportExportDTO> 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<String> 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<String> roleCodes) {
return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getNotifyUsers());
private boolean isDailyReportNotifyUsersVisible(Long userId, List<String> roleCodes) {
return isPermissionControlledFeatureVisible(
userId,
DAILY_REPORT_NOTIFY_USERS_PERMISSION,
roleCodes,
workReportProperties.getDailyReport().getNotifyUsers());
}
private boolean isDailyReportAttachmentsVisible(List<String> roleCodes) {
return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getAttachments());
private boolean isDailyReportAttachmentsVisible(Long userId, List<String> roleCodes) {
return isPermissionControlledFeatureVisible(
userId,
DAILY_REPORT_ATTACHMENTS_PERMISSION,
roleCodes,
workReportProperties.getDailyReport().getAttachments());
}
private boolean isSalesRole(List<String> roleCodes) {
return roleCodes != null && roleCodes.stream().anyMatch(SALES_ROLE_CODE::equals);
}
private boolean isFeatureVisible(List<String> roleCodes, WorkReportProperties.FeatureVisibility visibility) {
@ -549,6 +615,45 @@ public class WorkServiceImpl implements WorkService {
return safeVisibility.isVisibleByDefault();
}
private boolean isPermissionControlledFeatureVisible(
Long userId,
String permissionCode,
List<String> roleCodes,
WorkReportProperties.FeatureVisibility fallbackVisibility) {
if (!permissionCodeExists(permissionCode)) {
return isFeatureVisible(roleCodes, fallbackVisibility);
}
return loadPermissionCodes(userId).contains(permissionCode);
}
private Set<String> loadPermissionCodes(Long userId) {
try {
Long tenantId = resolveCurrentTenantId();
if (tenantId == null || sysPermissionService == null) {
return Set.of();
}
Set<String> 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<String> userRoleCodes, List<String> 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<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
private List<WorkHistoryItemDTO> loadHistoryItems(Long userId, HistoryType historyType, int fetchLimit) {
List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
if (historyType == null || historyType == HistoryType.CHECK_IN) {
historyItems.addAll(workMapper.selectCheckInHistory(fetchLimit));
List<Long> 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<Long> 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<Long> 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<Long> normalizeVisibleUserIds(List<Long> userIds, Long currentUserId) {
if (userIds == null || userIds.isEmpty()) {
return List.of();
}
Set<Long> normalized = new LinkedHashSet<>();
for (Long userId : userIds) {
if (userId != null && userId > 0 && !userId.equals(currentUserId)) {
normalized.add(userId);
}
}
return List.copyOf(normalized);
}
private List<WorkHistoryItemDTO> deduplicateHistoryItems(List<WorkHistoryItemDTO> items) {
Map<String, WorkHistoryItemDTO> byKey = new LinkedHashMap<>();
for (WorkHistoryItemDTO item : items) {
if (item != null) {
byKey.putIfAbsent(item.getType() + ":" + item.getId(), item);
}
}
return new ArrayList<>(byKey.values());
}
private List<WorkCheckInExportDTO> limitMergedWorkCheckInExportRows(
List<WorkCheckInExportDTO> scopedRows,
List<WorkCheckInExportDTO> extraRows) {
Map<String, WorkCheckInExportDTO> 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<WorkDailyReportExportDTO> limitMergedWorkDailyReportExportRows(
List<WorkDailyReportExportDTO> scopedRows,
List<WorkDailyReportExportDTO> extraRows) {
Map<String, WorkDailyReportExportDTO> 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<WorkCheckInExportDTO> buildCheckInExportComparator() {
return Comparator
.comparing(WorkCheckInExportDTO::getSortTime, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(WorkCheckInExportDTO::getId, Comparator.nullsLast(Comparator.reverseOrder()));
}
private Comparator<WorkDailyReportExportDTO> 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<WorkReportLineItemDTO> 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(

View File

@ -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]

View File

@ -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]

View File

@ -4,6 +4,231 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.ExpansionMapper">
<sql id="salesExpansionSelectColumns">
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
</sql>
<sql id="salesExpansionJoins">
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
</sql>
<sql id="salesExpansionKeywordFilter">
<if test="keyword != null and keyword != ''">
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}, '%')
)
</if>
</sql>
<sql id="channelExpansionSelectColumns">
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
</sql>
<sql id="channelExpansionJoins">
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
</sql>
<sql id="channelExpansionKeywordFilter">
<if test="keyword != null and keyword != ''">
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}, '%')
)
)
)
</if>
</sql>
<select id="selectDictItems" resultType="com.unis.crm.dto.expansion.DictOptionDTO">
select
item_label as label,
@ -136,6 +361,19 @@
order by s.updated_at desc, s.id desc
</select>
<select id="selectSalesExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
select
<include refid="salesExpansionSelectColumns"/>
from crm_sales_expansion s
<include refid="salesExpansionJoins"/>
where s.owner_user_id in
<foreach collection="ownerUserIds" item="ownerUserId" open="(" separator="," close=")">
#{ownerUserId}
</foreach>
<include refid="salesExpansionKeywordFilter"/>
order by s.updated_at desc, s.id desc
</select>
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
select
c.id,
@ -271,6 +509,19 @@
order by c.updated_at desc, c.id desc
</select>
<select id="selectChannelExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
select
<include refid="channelExpansionSelectColumns"/>
from crm_channel_expansion c
<include refid="channelExpansionJoins"/>
where c.owner_user_id in
<foreach collection="ownerUserIds" item="ownerUserId" open="(" separator="," close=")">
#{ownerUserId}
</foreach>
<include refid="channelExpansionKeywordFilter"/>
order by c.updated_at desc, c.id desc
</select>
<select id="selectSalesExpansionsForTenant" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
select
s.id,

View File

@ -377,10 +377,21 @@
coalesce(f.followup_type, '无') as type,
coalesce(f.content, '无') as content,
coalesce(f.next_action, '') as nextAction,
coalesce(u.display_name, '无') as user
coalesce(u.display_name, '无') as user,
coalesce(role_info.role_codes, '') as roleCodes
from crm_opportunity_followup f
join crm_opportunity o on o.id = f.opportunity_id
left join sys_user u on u.user_id = f.followup_user_id
left join (
select
ur.user_id,
string_agg(distinct r.role_code, ',' order by r.role_code) as role_codes
from sys_user_role ur
join sys_role r on r.role_id = ur.role_id
where coalesce(ur.is_deleted, 0) = 0
and coalesce(r.is_deleted, 0) = 0
group by ur.user_id
) role_info on role_info.user_id = f.followup_user_id
where f.opportunity_id in
<foreach collection="opportunityIds" item="id" open="(" separator="," close=")">
#{id}
@ -448,6 +459,7 @@
competitor_name,
latest_progress,
next_plan,
updated_by,
pushed_to_oms,
oms_push_time,
description,
@ -473,6 +485,7 @@
#{request.competitorName},
#{request.latestProgress},
#{request.nextPlan},
#{userId},
false,
null,
#{request.description},
@ -509,6 +522,7 @@
<update id="updateOpportunityCode">
update crm_opportunity o
set opportunity_code = #{opportunityCode},
updated_by = #{userId},
updated_at = now()
where o.id = #{opportunityId}
</update>
@ -575,6 +589,7 @@
update crm_opportunity o
set pre_sales_id = #{preSalesId},
pre_sales_name = #{preSalesName},
updated_by = #{userId},
updated_at = now()
where o.id = #{opportunityId}
</update>
@ -597,6 +612,7 @@
competitor_name = #{request.competitorName},
latest_progress = #{request.latestProgress},
next_plan = #{request.nextPlan},
updated_by = #{userId},
description = #{request.description},
status = case
when #{request.stage} = 'won' then 'won'
@ -711,6 +727,7 @@
set pushed_to_oms = true,
oms_push_time = now(),
opportunity_code = #{opportunityCode},
updated_by = #{userId},
updated_at = now()
where o.id = #{opportunityId}
</update>

View File

@ -4,6 +4,58 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.WorkMapper">
<sql id="checkInExportFilters">
<if test="startDate != null">
and c.checkin_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and c.checkin_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="bizType != null and bizType != ''">
and c.biz_type = #{bizType}
</if>
<if test="status != null and status != ''">
and coalesce(c.status, 'normal') = #{status}
</if>
</sql>
<sql id="dailyReportExportFilters">
<if test="startDate != null">
and r.report_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and r.report_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="status != null and status != ''">
and coalesce(r.status, 'submitted') = #{status}
</if>
</sql>
<select id="selectTodayCheckIn" resultType="com.unis.crm.dto.work.WorkCheckInDTO">
select
id,
@ -228,6 +280,42 @@
limit #{limit}
</select>
<select id="selectCheckInHistoryByUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
c.id,
'外勤打卡' as type,
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
to_char(c.checkin_time, 'HH24:MI') as time,
case
when c.biz_name is not null and btrim(c.biz_name) &lt;&gt; '' then '关联对象:' || c.biz_name || E'\n'
else ''
end ||
case
when c.user_name is not null and btrim(c.user_name) &lt;&gt; '' then '打卡人:' || c.user_name || E'\n'
else ''
end ||
coalesce(c.location_text, '') ||
case
when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注' || c.remark
else ''
end as content,
case coalesce(c.status, 'normal')
when 'normal' then '正常'
when 'updated' then '已更新'
else coalesce(c.status, '正常')
end as status,
null::integer as score,
null::text as comment,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
from work_checkin c
where c.user_id in
<foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
order by sort_time desc nulls last, id desc
limit #{limit}
</select>
<select id="selectReportHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
@ -270,6 +358,52 @@
limit #{limit}
</select>
<select id="selectReportHistoryByUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
case
when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
then '提交人:' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
else ''
end ||
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.user_id in
<foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
order by sort_time desc nulls last, id desc
limit #{limit}
</select>
<select id="selectReportHistoryById" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
@ -321,8 +455,55 @@
limit 1
</select>
<select id="selectReportHistoryByIdForUserIds" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
case
when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
then '提交人:' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
else ''
end ||
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.id = #{reportId}
and r.user_id in
<foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
limit 1
</select>
<select id="selectCheckInExportRows" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
select
c.id,
to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
to_char(c.checkin_time, 'HH24:MI') as checkinTime,
coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
@ -335,7 +516,8 @@
coalesce(c.remark, '') as remark,
coalesce(c.status, 'normal') as status,
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sortTime
from work_checkin c
left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
left join lateral (
@ -347,37 +529,50 @@
and o.is_deleted = 0
) org_info on true
where 1 = 1
<if test="startDate != null">
and c.checkin_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and c.checkin_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="bizType != null and bizType != ''">
and c.biz_type = #{bizType}
</if>
<if test="status != null and status != ''">
and coalesce(c.status, 'normal') = #{status}
</if>
<include refid="checkInExportFilters"/>
order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
limit #{limit}
</select>
<select id="selectCheckInExportRowsByUserIds" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
select
c.id,
to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
to_char(c.checkin_time, 'HH24:MI') as checkinTime,
coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') as deptName,
coalesce(c.biz_type, '') as bizType,
coalesce(c.biz_name, '') as bizName,
coalesce(c.location_text, '') as locationText,
c.longitude,
c.latitude,
coalesce(c.remark, '') as remark,
coalesce(c.status, 'normal') as status,
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sortTime
from work_checkin c
left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
left join lateral (
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
from sys_tenant_user tu
join sys_org o on o.id = tu.org_id
where tu.user_id = c.user_id
and tu.is_deleted = 0
and o.is_deleted = 0
) org_info on true
where c.user_id in
<foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
<include refid="checkInExportFilters"/>
order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
limit #{limit}
</select>
<select id="selectDailyReportExportRows" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
select
r.id,
to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
@ -391,7 +586,8 @@
coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sortTime
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join lateral (
@ -414,27 +610,55 @@
) rc on rc.report_id = r.id
left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
where 1 = 1
<if test="startDate != null">
and r.report_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and r.report_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="status != null and status != ''">
and coalesce(r.status, 'submitted') = #{status}
</if>
<include refid="dailyReportExportFilters"/>
order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
limit #{limit}
</select>
<select id="selectDailyReportExportRowsByUserIds" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
select
r.id,
to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
coalesce(nullif(btrim(org_info.org_names), ''), '') as deptName,
coalesce(r.source_type, 'manual') as sourceType,
coalesce(r.status, 'submitted') as status,
coalesce(r.work_content, '') as workContent,
coalesce(r.tomorrow_plan, '') as tomorrowPlan,
rc.score,
rc.comment_content as comment,
coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sortTime
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join lateral (
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
from sys_tenant_user tu
join sys_org o on o.id = tu.org_id
where tu.user_id = r.user_id
and tu.is_deleted = 0
and o.is_deleted = 0
) org_info on true
left join (
select distinct on (report_id)
report_id,
reviewer_user_id,
score,
comment_content,
reviewed_at
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
where r.user_id in
<foreach collection="visibleUserIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
<include refid="dailyReportExportFilters"/>
order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
limit #{limit}
</select>
@ -752,6 +976,7 @@
set latest_progress = #{latestProgress},
next_plan = #{nextPlan},
stage = coalesce(#{stage}, stage),
updated_by = #{updatedBy},
updated_at = now()
where id = #{opportunityId}
</update>

View File

@ -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.<Object[]>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);
}
}

View File

@ -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.<Object>any());
}
}

View File

@ -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();

View File

@ -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");

View File

@ -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<WorkCheckInExportDTO> 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<WorkDailyReportExportDTO> 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) {

View File

@ -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<string, { expiresAt: number; value: unknown }>();
const inFlightRequestCache = new Map<string, Promise<unknown>>();
let authExpiryTimer: number | null = null;
let refreshTokenPromise: Promise<string | null> | 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<TokenResponse, "accessToken" | "refreshToken" | "availableTenants">) {
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<string | null> {
if (refreshTokenPromise) {
return refreshTokenPromise;
}
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
return null;
}
refreshTokenPromise = request<TokenResponse>("/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<string | null> {
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<T>(rawText: string, contentType?: string | null): (ApiEnvelope<T> & ApiErrorBody) | null {
@ -1045,6 +1122,10 @@ async function request<T>(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<T>(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() {

View File

@ -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);
}
});

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-DNoKV8lp.js"></script>
<script type="module" crossorigin src="/assets/index-Bk1lir76.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -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;

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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<string, string> = {
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<string, Map<string, UserDataScopeGrant[]>>();
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<string, UserDataScopeGrant[]>();
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<string, UserDataScopeGrant[]>();
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<UserDataScopeUser[]>([]);
const [grants, setGrants] = useState<UserDataScopeGrant[]>([]);
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingGrantGroup, setEditingGrantGroup] = useState<UserDataScopeGrantGroup | null>(null);
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(() => 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: (
<Space direction="vertical" size={0}>
<span>{userLabel(user)}</span>
{user.orgName && <Text type="secondary" style={{ fontSize: 12 }}>{user.orgName}</Text>}
</Space>
),
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<UserDataScopeGrantGroup> = [
{
title: "查看人",
dataIndex: "viewerName",
width: 180,
render: (value: string, record) => (
<Space direction="vertical" size={0}>
<Text strong>{value}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>ID: {record.viewerUserId}</Text>
</Space>
)
},
{
title: "可见人员",
dataIndex: "ownerUsers",
width: 260,
render: (_: unknown, record) => (
<Space wrap size={[6, 6]}>
{record.ownerUsers.map((owner) => (
<Tag key={owner.userId}>
{owner.name}
<Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>ID: {owner.userId}</Text>
</Tag>
))}
</Space>
)
},
{
title: "资源范围",
dataIndex: "resourceTypes",
width: 220,
render: (_: unknown, record) => (
<Space wrap size={[6, 6]}>
{record.resourceTypes.map((value) => (
<Tag key={value} color={value === "ALL" ? "blue" : "geekblue"}>{resourceLabel(value)}</Tag>
))}
</Space>
)
},
{
title: "状态",
dataIndex: "enabled",
width: 100,
render: (enabled: boolean, record) => {
if (isGroupExpired(record)) {
return <Tag color="orange"></Tag>;
}
return <Tag color={enabled ? "green" : "red"}>{enabled ? "启用" : "停用"}</Tag>;
}
},
{
title: "有效期",
dataIndex: "expireAt",
width: 170,
render: (value?: string) => formatExpireAt(value)
},
{
title: "备注",
dataIndex: "remark",
ellipsis: true,
render: (value?: string) => value ? <Tooltip title={value}>{value}</Tooltip> : "-"
},
{
title: "操作",
key: "action",
width: 120,
fixed: "right",
render: (_: unknown, record) => (
<Space size={4}>
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
disabled={!can("user_data_scope:update")}
onClick={() => openEdit(record)}
/>
</Tooltip>
<Popconfirm title="确认删除该组授权?" okText="删除" cancelText="取消" onConfirm={() => handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} disabled={!can("user_data_scope:update")} />
</Popconfirm>
</Space>
)
}
];
return (
<div className="app-page user-data-scope-page">
<PageHeader title="数据授权管理" subtitle="查看、添加或删除某个用户可额外查看的人员数据权限。" />
<div className="user-data-scope-overview">
<div className="user-data-scope-stat">
<span className="user-data-scope-stat__label"></span>
<span className="user-data-scope-stat__value">{grantGroups.length}</span>
</div>
<div className="user-data-scope-stat">
<span className="user-data-scope-stat__label"></span>
<span className="user-data-scope-stat__value">{activeGrantCount}</span>
</div>
<div className="user-data-scope-stat">
<span className="user-data-scope-stat__label"></span>
<span className="user-data-scope-stat__value">{new Set(grants.map((grant) => grant.viewerUserId)).size}</span>
</div>
</div>
<Card className="app-page__filter-card user-data-scope-filter" styles={{ body: { padding: 16 } }}>
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Space wrap>
{platformMode && (
<Select
value={selectedTenantId}
placeholder="选择租户"
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
onChange={(value) => {
setSelectedTenantId(value);
setQuery((prev) => ({ ...prev, current: 1, viewerUserId: undefined }));
}}
style={{ width: 180 }}
/>
)}
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索查看人、可见人员、备注"
value={query.keyword}
onChange={(event) => setQuery((prev) => ({ ...prev, keyword: event.target.value, current: 1 }))}
style={{ width: 260 }}
/>
<Select
allowClear
showSearch
placeholder="筛选查看人"
value={query.viewerUserId}
options={userOptions}
optionFilterProp="searchText"
optionLabelProp="displayLabel"
onChange={(value) => setQuery((prev) => ({ ...prev, viewerUserId: value, current: 1 }))}
style={{ width: 220 }}
/>
<Select
allowClear
placeholder="资源范围"
value={query.resourceType}
options={resourceOptions}
onChange={(value) => setQuery((prev) => ({ ...prev, resourceType: value, current: 1 }))}
style={{ width: 140 }}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} disabled={!can("user_data_scope:update")} onClick={openCreate}>
</Button>
</div>
</Card>
<Card className="app-page__content-card user-data-scope-table-card" styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={pagedGrantGroups}
size="middle"
scroll={{ y: "calc(100vh - 390px)", x: 980 }}
pagination={getStandardPagination(
filteredGrantGroups.length,
query.current,
query.pageSize,
(page, pageSize) => setQuery((prev) => ({ ...prev, current: page, pageSize }))
)}
/>
</Card>
<Drawer
title={<span><TeamOutlined className="mr-2" />{editingGrantGroup ? "编辑数据授权" : "新建数据授权"}</span>}
open={drawerOpen}
onClose={closeDrawer}
width={560}
destroyOnHidden
footer={<div className="app-page__drawer-footer"><Button onClick={closeDrawer}></Button><Button type="primary" loading={saving} onClick={handleSave}></Button></div>}
>
<Form form={form} layout="vertical" initialValues={{ resourceTypes: ["OPPORTUNITY"], enabled: true, ownerUserIds: [] }}>
<Form.Item label="查看人" name="viewerUserId" rules={[{ required: true, message: "请选择查看人" }]}>
<Select
showSearch
placeholder="选择谁可以看数据"
options={userOptions}
optionFilterProp="searchText"
optionLabelProp="displayLabel"
disabled={Boolean(editingGrantGroup)}
/>
</Form.Item>
<Form.Item label="资源范围" name="resourceTypes" rules={[{ required: true, message: "请选择资源范围" }]}>
<Select mode="multiple" allowClear options={resourceOptions} maxTagCount="responsive" />
</Form.Item>
<Form.Item label="可见人员" name="ownerUserIds" rules={[{ required: true, message: "请选择可见人员" }]}>
<Select
mode="multiple"
showSearch
allowClear
placeholder="选择允许查看的数据归属人"
options={ownerOptions}
optionFilterProp="searchText"
optionLabelProp="displayLabel"
maxTagCount="responsive"
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="启用状态" name="enabled">
<Select options={[{ label: "启用", value: true }, { label: "停用", value: false }]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="有效期" name="expireAt">
<DatePicker showTime allowClear placeholder="永久" style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} maxLength={500} placeholder="例如:临时支持某销售团队的数据查看" />
</Form.Item>
</Form>
</Drawer>
</div>
);
}

View File

@ -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: <LazyPage><PlatformSettings /></LazyPage>, perm: "menu:platform" },
{ path: "/dictionaries", label: "字典管理", element: <LazyPage><Dictionaries /></LazyPage>, perm: "menu:dict" },
{ path: "/logs", label: "日志管理", element: <LazyPage><Logs /></LazyPage>, perm: "menu:logs" },
{ path: "/data-scope-users", label: "数据授权管理", element: <LazyPage><UserDataScope /></LazyPage>, perm: "menu:data-scope-users" },
{ path: "/dashboard-analytics-settings", label: "首页经营分析配置", element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>, perm: "menu:dashboard-analytics-settings" },
{ path: "/report-reminder-settings", label: "日报提醒设置", element: <LazyPage><ReportReminderSettings /></LazyPage>, perm: "menu:report-reminder-settings" },
{ path: "/owner-transfer", label: "归属人转移", element: <LazyPage><OwnerTransfer /></LazyPage>, perm: "menu:owner-transfer" },

View File

@ -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;

View File

@ -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';

View File

@ -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
$$;

View File

@ -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;

View File

@ -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'),