权限控制到商机的业绩归属地,优化
parent
cd8feef377
commit
8b2838c086
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -32,6 +32,7 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
|||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint");
|
||||
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 project_ownership_location 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");
|
||||
|
|
@ -39,6 +40,7 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
|||
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.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code'");
|
||||
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'");
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ public class UserDataScopeSchemaInitializer implements ApplicationRunner {
|
|||
on sys_user_data_scope_user (tenant_id, viewer_user_id, owner_user_id, resource_type)
|
||||
where is_deleted = 0
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table sys_user_data_scope_user
|
||||
add column if not exists area_scope_type varchar(20) not null default 'ALL'
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table sys_user_data_scope_user
|
||||
add column if not exists area_codes varchar(100)[] not null default '{}'::varchar[]
|
||||
""");
|
||||
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)
|
||||
|
|
@ -58,6 +66,8 @@ public class UserDataScopeSchemaInitializer implements ApplicationRunner {
|
|||
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等'");
|
||||
statement.execute("comment on column sys_user_data_scope_user.area_scope_type is '商机业绩归属地范围:ALL全部归属地/CUSTOM指定归属地'");
|
||||
statement.execute("comment on column sys_user_data_scope_user.area_codes is '指定可见的商机业绩归属地编码列表,对应 cnarea.area_code'");
|
||||
log.info("Ensured sys_user_data_scope_user exists");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Failed to initialize user data scope schema", exception);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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.dto.opportunity.OpportunityDictOptionDTO;
|
||||
import com.unis.crm.service.UserDataScopeAdminService;
|
||||
import com.unisbase.common.annotation.Log;
|
||||
import java.util.List;
|
||||
|
|
@ -40,6 +41,12 @@ public class UserDataScopeAdminController {
|
|||
return ApiResponse.success(userDataScopeAdminService.listGrants(tenantId, viewerUserId, resourceType));
|
||||
}
|
||||
|
||||
@GetMapping("/project-ownership-locations")
|
||||
public ApiResponse<List<OpportunityDictOptionDTO>> listProjectOwnershipLocations(
|
||||
@RequestParam(value = "tenantId", required = false) Long tenantId) {
|
||||
return ApiResponse.success(userDataScopeAdminService.listProjectOwnershipLocations(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/assignment")
|
||||
public ApiResponse<UserDataScopeAssignmentDTO> getAssignment(
|
||||
@RequestParam(value = "tenantId", required = false) Long tenantId,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ public class UserDataScopeAssignmentDTO {
|
|||
private String resourceType;
|
||||
private List<String> resourceTypes = new ArrayList<>();
|
||||
private List<Long> ownerUserIds = new ArrayList<>();
|
||||
private List<UserDataScopeOwnerRuleDTO> ownerRules = new ArrayList<>();
|
||||
private String areaScopeType;
|
||||
private List<String> areaCodes = new ArrayList<>();
|
||||
private OffsetDateTime expireAt;
|
||||
private String remark;
|
||||
private Boolean enabled;
|
||||
|
|
@ -55,6 +58,30 @@ public class UserDataScopeAssignmentDTO {
|
|||
this.ownerUserIds = ownerUserIds;
|
||||
}
|
||||
|
||||
public List<UserDataScopeOwnerRuleDTO> getOwnerRules() {
|
||||
return ownerRules;
|
||||
}
|
||||
|
||||
public void setOwnerRules(List<UserDataScopeOwnerRuleDTO> ownerRules) {
|
||||
this.ownerRules = ownerRules;
|
||||
}
|
||||
|
||||
public String getAreaScopeType() {
|
||||
return areaScopeType;
|
||||
}
|
||||
|
||||
public void setAreaScopeType(String areaScopeType) {
|
||||
this.areaScopeType = areaScopeType;
|
||||
}
|
||||
|
||||
public List<String> getAreaCodes() {
|
||||
return areaCodes;
|
||||
}
|
||||
|
||||
public void setAreaCodes(List<String> areaCodes) {
|
||||
this.areaCodes = areaCodes;
|
||||
}
|
||||
|
||||
public OffsetDateTime getExpireAt() {
|
||||
return expireAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ public class UserDataScopeGrantDTO {
|
|||
private Long ownerUserId;
|
||||
private String ownerName;
|
||||
private String resourceType;
|
||||
private String areaScopeType;
|
||||
private java.util.List<String> areaCodes = new java.util.ArrayList<>();
|
||||
private java.util.List<String> areaNames = new java.util.ArrayList<>();
|
||||
private Boolean enabled;
|
||||
private OffsetDateTime expireAt;
|
||||
private String remark;
|
||||
|
|
@ -73,6 +76,30 @@ public class UserDataScopeGrantDTO {
|
|||
this.resourceType = resourceType;
|
||||
}
|
||||
|
||||
public String getAreaScopeType() {
|
||||
return areaScopeType;
|
||||
}
|
||||
|
||||
public void setAreaScopeType(String areaScopeType) {
|
||||
this.areaScopeType = areaScopeType;
|
||||
}
|
||||
|
||||
public java.util.List<String> getAreaCodes() {
|
||||
return areaCodes;
|
||||
}
|
||||
|
||||
public void setAreaCodes(java.util.List<String> areaCodes) {
|
||||
this.areaCodes = areaCodes;
|
||||
}
|
||||
|
||||
public java.util.List<String> getAreaNames() {
|
||||
return areaNames;
|
||||
}
|
||||
|
||||
public void setAreaNames(java.util.List<String> areaNames) {
|
||||
this.areaNames = areaNames;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package com.unis.crm.dto.datascope;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class UserDataScopeOwnerRuleDTO {
|
||||
|
||||
private Long ownerUserId;
|
||||
private String ownerName;
|
||||
private String areaScopeType;
|
||||
private List<String> areaCodes = new ArrayList<>();
|
||||
private List<String> areaNames = new ArrayList<>();
|
||||
|
||||
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 getAreaScopeType() {
|
||||
return areaScopeType;
|
||||
}
|
||||
|
||||
public void setAreaScopeType(String areaScopeType) {
|
||||
this.areaScopeType = areaScopeType;
|
||||
}
|
||||
|
||||
public List<String> getAreaCodes() {
|
||||
return areaCodes;
|
||||
}
|
||||
|
||||
public void setAreaCodes(List<String> areaCodes) {
|
||||
this.areaCodes = areaCodes;
|
||||
}
|
||||
|
||||
public List<String> getAreaNames() {
|
||||
return areaNames;
|
||||
}
|
||||
|
||||
public void setAreaNames(List<String> areaNames) {
|
||||
this.areaNames = areaNames;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,9 @@ public class CreateOpportunityRequest {
|
|||
@Size(max = 100, message = "项目地不能超过100字符")
|
||||
private String projectLocation;
|
||||
|
||||
@Size(max = 100, message = "业绩归属地不能超过100字符")
|
||||
private String projectOwnershipLocation;
|
||||
|
||||
@Size(max = 100, message = "运作方不能超过100字符")
|
||||
private String operatorName;
|
||||
|
||||
|
|
@ -85,6 +88,14 @@ public class CreateOpportunityRequest {
|
|||
this.projectLocation = projectLocation;
|
||||
}
|
||||
|
||||
public String getProjectOwnershipLocation() {
|
||||
return projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public void setProjectOwnershipLocation(String projectOwnershipLocation) {
|
||||
this.projectOwnershipLocation = projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public class OpportunityItemDTO {
|
|||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String projectLocation;
|
||||
private String projectOwnershipLocation;
|
||||
private String projectOwnershipLocationName;
|
||||
private String operatorCode;
|
||||
private String operatorName;
|
||||
private BigDecimal amount;
|
||||
|
|
@ -117,6 +119,22 @@ public class OpportunityItemDTO {
|
|||
this.projectLocation = projectLocation;
|
||||
}
|
||||
|
||||
public String getProjectOwnershipLocation() {
|
||||
return projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public void setProjectOwnershipLocation(String projectOwnershipLocation) {
|
||||
this.projectOwnershipLocation = projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public String getProjectOwnershipLocationName() {
|
||||
return projectOwnershipLocationName;
|
||||
}
|
||||
|
||||
public void setProjectOwnershipLocationName(String projectOwnershipLocationName) {
|
||||
this.projectOwnershipLocationName = projectOwnershipLocationName;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public class OpportunityMetaDTO {
|
|||
private List<OpportunityDictOptionDTO> stageOptions;
|
||||
private List<OpportunityDictOptionDTO> operatorOptions;
|
||||
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
||||
private List<OpportunityDictOptionDTO> projectOwnershipLocationOptions;
|
||||
private List<OpportunityDictOptionDTO> opportunityTypeOptions;
|
||||
private List<OpportunityDictOptionDTO> confidenceOptions;
|
||||
private List<OpportunityDictOptionDTO> sysIsOptions;
|
||||
|
|
@ -18,12 +19,14 @@ public class OpportunityMetaDTO {
|
|||
List<OpportunityDictOptionDTO> stageOptions,
|
||||
List<OpportunityDictOptionDTO> operatorOptions,
|
||||
List<OpportunityDictOptionDTO> projectLocationOptions,
|
||||
List<OpportunityDictOptionDTO> projectOwnershipLocationOptions,
|
||||
List<OpportunityDictOptionDTO> opportunityTypeOptions,
|
||||
List<OpportunityDictOptionDTO> confidenceOptions,
|
||||
List<OpportunityDictOptionDTO> sysIsOptions) {
|
||||
this.stageOptions = stageOptions;
|
||||
this.operatorOptions = operatorOptions;
|
||||
this.projectLocationOptions = projectLocationOptions;
|
||||
this.projectOwnershipLocationOptions = projectOwnershipLocationOptions;
|
||||
this.opportunityTypeOptions = opportunityTypeOptions;
|
||||
this.confidenceOptions = confidenceOptions;
|
||||
this.sysIsOptions = sysIsOptions;
|
||||
|
|
@ -53,6 +56,14 @@ public class OpportunityMetaDTO {
|
|||
this.projectLocationOptions = projectLocationOptions;
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> getProjectOwnershipLocationOptions() {
|
||||
return projectOwnershipLocationOptions;
|
||||
}
|
||||
|
||||
public void setProjectOwnershipLocationOptions(List<OpportunityDictOptionDTO> projectOwnershipLocationOptions) {
|
||||
this.projectOwnershipLocationOptions = projectOwnershipLocationOptions;
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> getOpportunityTypeOptions() {
|
||||
return opportunityTypeOptions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
@Size(max = 100, message = "项目地不能超过100字符")
|
||||
private String projectLocation;
|
||||
|
||||
@Size(max = 100, message = "业绩归属地不能超过100字符")
|
||||
private String projectOwnershipLocation;
|
||||
|
||||
@Size(max = 100, message = "运作方不能超过100字符")
|
||||
private String operatorName;
|
||||
|
||||
|
|
@ -101,6 +104,14 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
this.projectLocation = projectLocation;
|
||||
}
|
||||
|
||||
public String getProjectOwnershipLocation() {
|
||||
return projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public void setProjectOwnershipLocation(String projectOwnershipLocation) {
|
||||
this.projectOwnershipLocation = projectOwnershipLocation;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider {
|
|||
@Override
|
||||
protected Map<String, Object> buildInputSchema() {
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("keyword", stringProperty("关键词,匹配商机名称、编号、客户名称、项目地点、产品类型。"));
|
||||
properties.put("keyword", stringProperty("关键词,匹配商机名称、编号、客户名称、项目地、业绩归属地、产品类型。"));
|
||||
properties.put("stage", stringProperty("商机阶段编码,例如 initial_contact/solution_discussion/bidding/business_negotiation/won/lost。"));
|
||||
properties.put("ownerUserId", integerProperty("商机负责人用户 ID,实际可见范围仍按系统数据权限裁剪。"));
|
||||
properties.put("includeArchived", booleanProperty("是否包含已签单商机,默认 false。"));
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
|
|||
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.OwnerAreaRule;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
|
@ -22,6 +23,10 @@ public interface OpportunityMapper {
|
|||
|
||||
List<OpportunityDictOptionDTO> selectProvinceAreaOptions();
|
||||
|
||||
List<OpportunityDictOptionDTO> selectProjectOwnershipLocationOptions();
|
||||
|
||||
String selectProvinceAreaCodeByAnyName(@Param("value") String value);
|
||||
|
||||
String selectDictLabel(
|
||||
@Param("typeCode") String typeCode,
|
||||
@Param("itemValue") String itemValue);
|
||||
|
|
@ -36,6 +41,7 @@ public interface OpportunityMapper {
|
|||
@Param("stage") String stage,
|
||||
@Param("allDataAccess") boolean allDataAccess,
|
||||
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
|
||||
@Param("visibleOwnerAreaRules") List<OwnerAreaRule> visibleOwnerAreaRules,
|
||||
@Param("preSalesUserId") Long preSalesUserId,
|
||||
@Param("preSalesUserNames") List<String> preSalesUserNames);
|
||||
|
||||
|
|
@ -44,6 +50,7 @@ public interface OpportunityMapper {
|
|||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("allDataAccess") boolean allDataAccess,
|
||||
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
|
||||
@Param("visibleOwnerAreaRules") List<OwnerAreaRule> visibleOwnerAreaRules,
|
||||
@Param("preSalesUserId") Long preSalesUserId,
|
||||
@Param("preSalesUserNames") List<String> preSalesUserNames);
|
||||
|
||||
|
|
@ -52,6 +59,7 @@ public interface OpportunityMapper {
|
|||
@Param("opportunityIds") List<Long> opportunityIds,
|
||||
@Param("allDataAccess") boolean allDataAccess,
|
||||
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
|
||||
@Param("visibleOwnerAreaRules") List<OwnerAreaRule> visibleOwnerAreaRules,
|
||||
@Param("preSalesUserId") Long preSalesUserId,
|
||||
@Param("preSalesUserNames") List<String> preSalesUserNames);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ public interface CrmDataVisibilityService {
|
|||
|
||||
record DataVisibility(
|
||||
boolean allDataAccess,
|
||||
List<Long> visibleOwnerUserIds) {
|
||||
List<Long> visibleOwnerUserIds,
|
||||
List<OwnerAreaRule> visibleOwnerAreaRules) {
|
||||
}
|
||||
|
||||
record OwnerAreaRule(
|
||||
Long ownerUserId,
|
||||
boolean allAreas,
|
||||
List<String> areaCodes) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,25 @@ 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.UserDataScopeOwnerRuleDTO;
|
||||
import com.unis.crm.dto.datascope.UserDataScopeUserDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
|
||||
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.sql.Array;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -34,6 +41,8 @@ public class UserDataScopeAdminService {
|
|||
"CHECKIN",
|
||||
"CUSTOMER",
|
||||
"WORK");
|
||||
private static final String AREA_SCOPE_ALL = "ALL";
|
||||
private static final String AREA_SCOPE_CUSTOM = "CUSTOM";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PermissionService permissionService;
|
||||
|
|
@ -91,6 +100,15 @@ public class UserDataScopeAdminService {
|
|||
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,
|
||||
coalesce(nullif(g.area_scope_type, ''), 'ALL') as area_scope_type,
|
||||
coalesce(g.area_codes, '{}'::varchar[]) as area_codes,
|
||||
coalesce((
|
||||
select array_agg(coalesce(a.short_name, a.name, ac.area_code) order by ac.area_code)
|
||||
from unnest(coalesce(g.area_codes, '{}'::varchar[])) as ac(area_code)
|
||||
left join cnarea a
|
||||
on a.level = 1
|
||||
and a.area_code = ac.area_code
|
||||
), '{}'::varchar[]) as area_names,
|
||||
g.enabled,
|
||||
g.expire_at,
|
||||
g.remark,
|
||||
|
|
@ -114,6 +132,36 @@ public class UserDataScopeAdminService {
|
|||
return jdbcTemplate.query(sql.toString(), (resultSet, rowNum) -> mapGrant(resultSet), args.toArray());
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> listProjectOwnershipLocations(Long tenantId) {
|
||||
resolveTenantId(tenantId);
|
||||
requirePermission(VIEW_PERM, "无权查看数据授权配置");
|
||||
try {
|
||||
Boolean cnareaExists = jdbcTemplate.queryForObject("select to_regclass('cnarea') is not null", Boolean.class);
|
||||
if (Boolean.TRUE.equals(cnareaExists)) {
|
||||
return jdbcTemplate.query("""
|
||||
select
|
||||
short_name as label,
|
||||
area_code as value
|
||||
from cnarea
|
||||
where level = 1
|
||||
and coalesce(nullif(btrim(area_code), ''), '') <> ''
|
||||
and coalesce(nullif(btrim(short_name), ''), '') <> ''
|
||||
order by area_code asc, id asc
|
||||
""", (resultSet, rowNum) -> mapDictOption(resultSet));
|
||||
}
|
||||
} catch (DataAccessException ignored) {
|
||||
// Fall back to existing opportunity values when the area dictionary is not installed.
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
select distinct
|
||||
project_ownership_location as label,
|
||||
project_ownership_location as value
|
||||
from crm_opportunity
|
||||
where coalesce(nullif(btrim(project_ownership_location), ''), '') <> ''
|
||||
order by project_ownership_location asc
|
||||
""", (resultSet, rowNum) -> mapDictOption(resultSet));
|
||||
}
|
||||
|
||||
public UserDataScopeAssignmentDTO getAssignment(Long tenantId, Long viewerUserId, String resourceType) {
|
||||
Long resolvedTenantId = resolveTenantId(tenantId);
|
||||
requirePermission(VIEW_PERM, "无权查看数据授权配置");
|
||||
|
|
@ -132,14 +180,22 @@ public class UserDataScopeAdminService {
|
|||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
dto.setOwnerRules(grants.stream()
|
||||
.map(this::toOwnerRule)
|
||||
.toList());
|
||||
grants.stream().findFirst().ifPresent(first -> {
|
||||
dto.setEnabled(first.getEnabled());
|
||||
dto.setExpireAt(first.getExpireAt());
|
||||
dto.setRemark(first.getRemark());
|
||||
dto.setAreaScopeType(first.getAreaScopeType());
|
||||
dto.setAreaCodes(first.getAreaCodes());
|
||||
});
|
||||
if (dto.getEnabled() == null) {
|
||||
dto.setEnabled(Boolean.TRUE);
|
||||
}
|
||||
if (dto.getAreaScopeType() == null) {
|
||||
dto.setAreaScopeType(AREA_SCOPE_ALL);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -154,8 +210,8 @@ public class UserDataScopeAdminService {
|
|||
List<String> resourceTypes = normalizeResourceTypes(payload.getResourceTypes(), payload.getResourceType());
|
||||
requireUserInTenant(tenantId, viewerUserId, "查看人不存在或不属于当前租户");
|
||||
|
||||
List<Long> ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId);
|
||||
validateOwnersInTenant(tenantId, ownerUserIds);
|
||||
List<UserDataScopeOwnerRuleDTO> ownerRules = normalizeOwnerRules(payload, viewerUserId);
|
||||
validateOwnersInTenant(tenantId, ownerRules.stream().map(UserDataScopeOwnerRuleDTO::getOwnerUserId).toList());
|
||||
boolean enabled = payload.getEnabled() == null || Boolean.TRUE.equals(payload.getEnabled());
|
||||
Long createdBy = userProvider == null ? null : userProvider.getCurrentUserId();
|
||||
|
||||
|
|
@ -171,13 +227,17 @@ public class UserDataScopeAdminService {
|
|||
and coalesce(is_deleted, 0) = 0
|
||||
""", tenantId, viewerUserId, resourceType);
|
||||
|
||||
for (Long ownerUserId : ownerUserIds) {
|
||||
for (UserDataScopeOwnerRuleDTO ownerRule : ownerRules) {
|
||||
boolean customAreaScope = CrmDataVisibilityService.RESOURCE_OPPORTUNITY.equals(resourceType)
|
||||
&& AREA_SCOPE_CUSTOM.equals(ownerRule.getAreaScopeType());
|
||||
jdbcTemplate.update("""
|
||||
insert into sys_user_data_scope_user (
|
||||
tenant_id,
|
||||
viewer_user_id,
|
||||
owner_user_id,
|
||||
resource_type,
|
||||
area_scope_type,
|
||||
area_codes,
|
||||
enabled,
|
||||
expire_at,
|
||||
remark,
|
||||
|
|
@ -185,12 +245,14 @@ public class UserDataScopeAdminService {
|
|||
created_at,
|
||||
updated_at,
|
||||
is_deleted
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, now(), now(), 0)
|
||||
) values (?, ?, ?, ?, ?, ?::varchar[], ?, ?, ?, ?, now(), now(), 0)
|
||||
""",
|
||||
tenantId,
|
||||
viewerUserId,
|
||||
ownerUserId,
|
||||
ownerRule.getOwnerUserId(),
|
||||
resourceType,
|
||||
customAreaScope ? AREA_SCOPE_CUSTOM : AREA_SCOPE_ALL,
|
||||
customAreaScope ? ownerRule.getAreaCodes().toArray(String[]::new) : new String[0],
|
||||
enabled,
|
||||
payload.getExpireAt(),
|
||||
trimToNull(payload.getRemark()),
|
||||
|
|
@ -233,6 +295,13 @@ public class UserDataScopeAdminService {
|
|||
return dto;
|
||||
}
|
||||
|
||||
private OpportunityDictOptionDTO mapDictOption(ResultSet resultSet) throws SQLException {
|
||||
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
|
||||
option.setLabel(resultSet.getString("label"));
|
||||
option.setValue(resultSet.getString("value"));
|
||||
return option;
|
||||
}
|
||||
|
||||
private UserDataScopeGrantDTO mapGrant(ResultSet resultSet) throws SQLException {
|
||||
UserDataScopeGrantDTO dto = new UserDataScopeGrantDTO();
|
||||
dto.setId(resultSet.getLong("id"));
|
||||
|
|
@ -242,6 +311,9 @@ public class UserDataScopeAdminService {
|
|||
dto.setOwnerUserId(resultSet.getLong("owner_user_id"));
|
||||
dto.setOwnerName(resultSet.getString("owner_name"));
|
||||
dto.setResourceType(resultSet.getString("resource_type"));
|
||||
dto.setAreaScopeType(resultSet.getString("area_scope_type"));
|
||||
dto.setAreaCodes(readStringArray(resultSet, "area_codes"));
|
||||
dto.setAreaNames(readStringArray(resultSet, "area_names"));
|
||||
dto.setEnabled(resultSet.getBoolean("enabled"));
|
||||
dto.setExpireAt(resultSet.getObject("expire_at", OffsetDateTime.class));
|
||||
dto.setRemark(resultSet.getString("remark"));
|
||||
|
|
@ -298,6 +370,63 @@ public class UserDataScopeAdminService {
|
|||
}
|
||||
}
|
||||
|
||||
private List<UserDataScopeOwnerRuleDTO> normalizeOwnerRules(UserDataScopeAssignmentDTO payload, Long viewerUserId) {
|
||||
List<UserDataScopeOwnerRuleDTO> ownerRules = payload.getOwnerRules();
|
||||
if (ownerRules != null && !ownerRules.isEmpty()) {
|
||||
Map<Long, UserDataScopeOwnerRuleDTO> normalized = ownerRules.stream()
|
||||
.map(rule -> normalizeOwnerRule(rule, viewerUserId))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(
|
||||
UserDataScopeOwnerRuleDTO::getOwnerUserId,
|
||||
rule -> rule,
|
||||
(first, ignored) -> first,
|
||||
java.util.LinkedHashMap::new));
|
||||
return List.copyOf(normalized.values());
|
||||
}
|
||||
|
||||
List<Long> ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId);
|
||||
String areaScopeType = normalizeAreaScopeType(payload.getAreaScopeType());
|
||||
List<String> areaCodes = normalizeAreaCodes(payload.getAreaCodes());
|
||||
if (AREA_SCOPE_CUSTOM.equals(areaScopeType) && areaCodes.isEmpty()) {
|
||||
throw new BusinessException("指定业绩归属地时请选择至少一个归属地");
|
||||
}
|
||||
List<UserDataScopeOwnerRuleDTO> fallbackRules = new ArrayList<>();
|
||||
for (Long ownerUserId : ownerUserIds) {
|
||||
UserDataScopeOwnerRuleDTO rule = new UserDataScopeOwnerRuleDTO();
|
||||
rule.setOwnerUserId(ownerUserId);
|
||||
rule.setAreaScopeType(areaScopeType);
|
||||
rule.setAreaCodes(areaCodes);
|
||||
fallbackRules.add(rule);
|
||||
}
|
||||
return List.copyOf(fallbackRules);
|
||||
}
|
||||
|
||||
private UserDataScopeOwnerRuleDTO normalizeOwnerRule(UserDataScopeOwnerRuleDTO source, Long viewerUserId) {
|
||||
if (source == null || source.getOwnerUserId() == null || source.getOwnerUserId() <= 0 || source.getOwnerUserId().equals(viewerUserId)) {
|
||||
return null;
|
||||
}
|
||||
UserDataScopeOwnerRuleDTO rule = new UserDataScopeOwnerRuleDTO();
|
||||
rule.setOwnerUserId(source.getOwnerUserId());
|
||||
String areaScopeType = normalizeAreaScopeType(source.getAreaScopeType());
|
||||
List<String> areaCodes = normalizeAreaCodes(source.getAreaCodes());
|
||||
if (AREA_SCOPE_CUSTOM.equals(areaScopeType) && areaCodes.isEmpty()) {
|
||||
throw new BusinessException("指定业绩归属地时请选择至少一个归属地");
|
||||
}
|
||||
rule.setAreaScopeType(areaScopeType);
|
||||
rule.setAreaCodes(areaCodes);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private UserDataScopeOwnerRuleDTO toOwnerRule(UserDataScopeGrantDTO grant) {
|
||||
UserDataScopeOwnerRuleDTO rule = new UserDataScopeOwnerRuleDTO();
|
||||
rule.setOwnerUserId(grant.getOwnerUserId());
|
||||
rule.setOwnerName(grant.getOwnerName());
|
||||
rule.setAreaScopeType(normalizeAreaScopeType(grant.getAreaScopeType()));
|
||||
rule.setAreaCodes(grant.getAreaCodes() == null ? List.of() : grant.getAreaCodes());
|
||||
rule.setAreaNames(grant.getAreaNames() == null ? List.of() : grant.getAreaNames());
|
||||
return rule;
|
||||
}
|
||||
|
||||
private List<Long> normalizeOwnerUserIds(List<Long> ownerUserIds, Long viewerUserId) {
|
||||
if (ownerUserIds == null || ownerUserIds.isEmpty()) {
|
||||
return List.of();
|
||||
|
|
@ -342,6 +471,53 @@ public class UserDataScopeAdminService {
|
|||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private String normalizeAreaScopeType(String areaScopeType) {
|
||||
String normalized = trimToNull(areaScopeType);
|
||||
if (normalized == null) {
|
||||
return AREA_SCOPE_ALL;
|
||||
}
|
||||
normalized = normalized.toUpperCase(Locale.ROOT);
|
||||
if (!Set.of(AREA_SCOPE_ALL, AREA_SCOPE_CUSTOM).contains(normalized)) {
|
||||
throw new BusinessException("不支持的业绩归属地范围");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private List<String> normalizeAreaCodes(List<String> areaCodes) {
|
||||
if (areaCodes == null || areaCodes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> normalized = new LinkedHashSet<>();
|
||||
for (String areaCode : areaCodes) {
|
||||
String value = trimToNull(areaCode);
|
||||
if (value != null) {
|
||||
normalized.add(value);
|
||||
}
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private List<String> readStringArray(ResultSet resultSet, String columnName) throws SQLException {
|
||||
Array sqlArray = resultSet.getArray(columnName);
|
||||
if (sqlArray == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object raw = sqlArray.getArray();
|
||||
if (raw instanceof String[] values) {
|
||||
return List.of(values);
|
||||
}
|
||||
if (raw instanceof Object[] values) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value != null) {
|
||||
result.add(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ package com.unis.crm.service.impl;
|
|||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unisbase.dto.DataScopeRuleDTO;
|
||||
import com.unisbase.service.DataScopeService;
|
||||
import java.sql.Array;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -24,32 +30,40 @@ public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService {
|
|||
@Override
|
||||
public DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType) {
|
||||
if (currentUserId == null || currentUserId <= 0) {
|
||||
return new DataVisibility(false, List.of());
|
||||
return new DataVisibility(false, List.of(), List.of());
|
||||
}
|
||||
if (tenantId == null || tenantId <= 0 || dataScopeService == null) {
|
||||
return new DataVisibility(false, List.of(currentUserId));
|
||||
return new DataVisibility(false, List.of(currentUserId), List.of(new OwnerAreaRule(currentUserId, true, List.of())));
|
||||
}
|
||||
|
||||
DataScopeRuleDTO baseRule = dataScopeService.resolveUserScope(currentUserId, tenantId);
|
||||
if (baseRule != null && baseRule.isAllAccess()) {
|
||||
return new DataVisibility(true, List.of());
|
||||
return new DataVisibility(true, List.of(), List.of());
|
||||
}
|
||||
|
||||
Set<Long> visibleOwnerUserIds = new LinkedHashSet<>();
|
||||
List<OwnerAreaRule> ownerAreaRules = new ArrayList<>();
|
||||
if (baseRule == null) {
|
||||
visibleOwnerUserIds.add(currentUserId);
|
||||
ownerAreaRules.add(new OwnerAreaRule(currentUserId, true, List.of()));
|
||||
} else {
|
||||
addValidUserIds(visibleOwnerUserIds, baseRule.getCreatorUserIds());
|
||||
addAllAreaRules(ownerAreaRules, baseRule.getCreatorUserIds());
|
||||
}
|
||||
addValidUserIds(visibleOwnerUserIds, queryExplicitVisibleOwnerUserIds(currentUserId, tenantId, resourceType));
|
||||
List<OwnerAreaRule> explicitOwnerAreaRules = queryExplicitVisibleOwnerAreaRules(currentUserId, tenantId, resourceType);
|
||||
addValidUserIds(visibleOwnerUserIds, explicitOwnerAreaRules.stream().map(OwnerAreaRule::ownerUserId).toList());
|
||||
ownerAreaRules.addAll(explicitOwnerAreaRules);
|
||||
|
||||
return new DataVisibility(false, List.copyOf(visibleOwnerUserIds));
|
||||
return new DataVisibility(false, List.copyOf(visibleOwnerUserIds), mergeOwnerAreaRules(ownerAreaRules));
|
||||
}
|
||||
|
||||
private List<Long> queryExplicitVisibleOwnerUserIds(Long currentUserId, Long tenantId, String resourceType) {
|
||||
private List<OwnerAreaRule> queryExplicitVisibleOwnerAreaRules(Long currentUserId, Long tenantId, String resourceType) {
|
||||
String normalizedResourceType = normalizeResourceType(resourceType);
|
||||
return jdbcTemplate.queryForList("""
|
||||
select distinct owner_user_id
|
||||
return jdbcTemplate.query("""
|
||||
select
|
||||
owner_user_id,
|
||||
coalesce(nullif(area_scope_type, ''), 'ALL') as area_scope_type,
|
||||
coalesce(area_codes, '{}'::varchar[]) as area_codes
|
||||
from sys_user_data_scope_user
|
||||
where tenant_id = ?
|
||||
and viewer_user_id = ?
|
||||
|
|
@ -60,13 +74,20 @@ public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService {
|
|||
and resource_type in (?, ?)
|
||||
order by owner_user_id asc
|
||||
""",
|
||||
Long.class,
|
||||
(resultSet, rowNum) -> mapOwnerAreaRule(resultSet),
|
||||
tenantId,
|
||||
currentUserId,
|
||||
RESOURCE_ALL,
|
||||
normalizedResourceType);
|
||||
}
|
||||
|
||||
private OwnerAreaRule mapOwnerAreaRule(ResultSet resultSet) throws SQLException {
|
||||
Long ownerUserId = resultSet.getLong("owner_user_id");
|
||||
String areaScopeType = resultSet.getString("area_scope_type");
|
||||
boolean allAreas = !"CUSTOM".equalsIgnoreCase(areaScopeType);
|
||||
return new OwnerAreaRule(ownerUserId, allAreas, readStringArray(resultSet, "area_codes"));
|
||||
}
|
||||
|
||||
private void addValidUserIds(Set<Long> target, List<Long> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return;
|
||||
|
|
@ -78,6 +99,65 @@ public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService {
|
|||
}
|
||||
}
|
||||
|
||||
private void addAllAreaRules(List<OwnerAreaRule> target, List<Long> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long userId : source) {
|
||||
if (userId != null && userId > 0) {
|
||||
target.add(new OwnerAreaRule(userId, true, List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<OwnerAreaRule> mergeOwnerAreaRules(List<OwnerAreaRule> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, List<OwnerAreaRule>> grouped = source.stream()
|
||||
.filter(rule -> rule != null && rule.ownerUserId() != null && rule.ownerUserId() > 0)
|
||||
.collect(Collectors.groupingBy(OwnerAreaRule::ownerUserId, java.util.LinkedHashMap::new, Collectors.toList()));
|
||||
List<OwnerAreaRule> merged = new ArrayList<>();
|
||||
for (Map.Entry<Long, List<OwnerAreaRule>> entry : grouped.entrySet()) {
|
||||
boolean allAreas = entry.getValue().stream().anyMatch(OwnerAreaRule::allAreas);
|
||||
if (allAreas) {
|
||||
merged.add(new OwnerAreaRule(entry.getKey(), true, List.of()));
|
||||
continue;
|
||||
}
|
||||
Set<String> areaCodes = new LinkedHashSet<>();
|
||||
for (OwnerAreaRule rule : entry.getValue()) {
|
||||
if (rule.areaCodes() != null) {
|
||||
areaCodes.addAll(rule.areaCodes());
|
||||
}
|
||||
}
|
||||
if (!areaCodes.isEmpty()) {
|
||||
merged.add(new OwnerAreaRule(entry.getKey(), false, List.copyOf(areaCodes)));
|
||||
}
|
||||
}
|
||||
return List.copyOf(merged);
|
||||
}
|
||||
|
||||
private List<String> readStringArray(ResultSet resultSet, String columnName) throws SQLException {
|
||||
Array sqlArray = resultSet.getArray(columnName);
|
||||
if (sqlArray == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object raw = sqlArray.getArray();
|
||||
if (raw instanceof String[] values) {
|
||||
return List.of(values);
|
||||
}
|
||||
if (raw instanceof Object[] values) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value != null) {
|
||||
result.add(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String normalizeResourceType(String resourceType) {
|
||||
if (resourceType == null || resourceType.trim().isEmpty()) {
|
||||
return RESOURCE_ALL;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
|
||||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
||||
opportunityMapper.selectProvinceAreaOptions(),
|
||||
opportunityMapper.selectProjectOwnershipLocationOptions(),
|
||||
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
|
||||
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions,
|
||||
buildSysIsOptions(opportunityMapper.selectDictItems(SYS_IS_TYPE_CODE)));
|
||||
|
|
@ -136,6 +137,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
normalizedStage,
|
||||
visibility.allDataAccess(),
|
||||
visibility.visibleOwnerUserIds(),
|
||||
visibility.visibleOwnerAreaRules(),
|
||||
visibility.preSalesUserId(),
|
||||
visibility.preSalesUserNames());
|
||||
attachFollowUps(userId, items, visibility);
|
||||
|
|
@ -154,6 +156,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
opportunityId,
|
||||
visibility.allDataAccess(),
|
||||
visibility.visibleOwnerUserIds(),
|
||||
visibility.visibleOwnerAreaRules(),
|
||||
visibility.preSalesUserId(),
|
||||
visibility.preSalesUserNames());
|
||||
if (item == null) {
|
||||
|
|
@ -301,6 +304,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
opportunityIds,
|
||||
visibility.allDataAccess(),
|
||||
visibility.visibleOwnerUserIds(),
|
||||
visibility.visibleOwnerAreaRules(),
|
||||
visibility.preSalesUserId(),
|
||||
visibility.preSalesUserNames())
|
||||
.stream()
|
||||
|
|
@ -314,12 +318,12 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
|
||||
private OpportunityVisibility resolveOpportunityVisibility(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
return new OpportunityVisibility(false, List.of(), userId, List.of());
|
||||
return new OpportunityVisibility(false, List.of(), List.of(), userId, List.of());
|
||||
}
|
||||
List<String> preSalesUserNames = resolveCurrentUserPreSalesNames(userId);
|
||||
Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId();
|
||||
if (tenantId == null || crmDataVisibilityService == null) {
|
||||
return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames);
|
||||
return new OpportunityVisibility(false, List.of(userId), List.of(new CrmDataVisibilityService.OwnerAreaRule(userId, true, List.of())), userId, preSalesUserNames);
|
||||
}
|
||||
|
||||
DataVisibility visibility = crmDataVisibilityService.resolveVisibility(
|
||||
|
|
@ -329,6 +333,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return new OpportunityVisibility(
|
||||
visibility.allDataAccess(),
|
||||
normalizeVisibleOwnerUserIds(visibility.visibleOwnerUserIds()),
|
||||
normalizeVisibleOwnerAreaRules(visibility.visibleOwnerAreaRules()),
|
||||
userId,
|
||||
preSalesUserNames);
|
||||
}
|
||||
|
|
@ -364,6 +369,33 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return List.copyOf(normalizedIds);
|
||||
}
|
||||
|
||||
private List<CrmDataVisibilityService.OwnerAreaRule> normalizeVisibleOwnerAreaRules(List<CrmDataVisibilityService.OwnerAreaRule> ownerAreaRules) {
|
||||
if (ownerAreaRules == null || ownerAreaRules.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CrmDataVisibilityService.OwnerAreaRule> normalizedRules = new ArrayList<>();
|
||||
for (CrmDataVisibilityService.OwnerAreaRule rule : ownerAreaRules) {
|
||||
if (rule == null || rule.ownerUserId() == null || rule.ownerUserId() <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (rule.allAreas()) {
|
||||
normalizedRules.add(new CrmDataVisibilityService.OwnerAreaRule(rule.ownerUserId(), true, List.of()));
|
||||
continue;
|
||||
}
|
||||
List<String> areaCodes = rule.areaCodes() == null
|
||||
? List.of()
|
||||
: rule.areaCodes().stream()
|
||||
.map(this::normalizeOptionalText)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!areaCodes.isEmpty()) {
|
||||
normalizedRules.add(new CrmDataVisibilityService.OwnerAreaRule(rule.ownerUserId(), false, areaCodes));
|
||||
}
|
||||
}
|
||||
return List.copyOf(normalizedRules);
|
||||
}
|
||||
|
||||
private void fillFollowUpDisplayFields(OpportunityFollowUpDTO followUp) {
|
||||
AttachmentMetadata attachmentMetadata = extractReportAttachmentMetadata(followUp.getContent());
|
||||
followUp.setContent(attachmentMetadata.cleanText());
|
||||
|
|
@ -501,6 +533,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
|
||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地"));
|
||||
request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode(request.getProjectOwnershipLocation()));
|
||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
|
||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
||||
request.setDescription(normalizeOptionalText(request.getDescription()));
|
||||
|
|
@ -701,6 +734,15 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return normalizeOptionalText(value);
|
||||
}
|
||||
|
||||
private String normalizeProjectOwnershipLocationCode(String value) {
|
||||
String normalized = normalizeOptionalText(value);
|
||||
if (normalized == null) {
|
||||
return null;
|
||||
}
|
||||
String areaCode = opportunityMapper.selectProvinceAreaCodeByAnyName(normalized);
|
||||
return isBlank(areaCode) ? normalized : areaCode.trim();
|
||||
}
|
||||
|
||||
private String resolveOpportunityCodeForUpdate(String existingOpportunityCode, String returnedOpportunityCode) {
|
||||
String normalizedExisting = normalizeOpportunityCode(existingOpportunityCode);
|
||||
if (normalizedExisting != null && !normalizedExisting.toUpperCase().startsWith("OPP-")) {
|
||||
|
|
@ -805,6 +847,9 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (request.getProjectLocation() != null) {
|
||||
request.setProjectLocation(trimToEmpty(request.getProjectLocation()));
|
||||
}
|
||||
if (request.getProjectOwnershipLocation() != null) {
|
||||
request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode(request.getProjectOwnershipLocation()));
|
||||
}
|
||||
if (request.getOperatorName() != null) {
|
||||
request.setOperatorName(trimToEmpty(request.getOperatorName()));
|
||||
}
|
||||
|
|
@ -1070,6 +1115,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
private record OpportunityVisibility(
|
||||
boolean allDataAccess,
|
||||
List<Long> visibleOwnerUserIds,
|
||||
List<CrmDataVisibilityService.OwnerAreaRule> visibleOwnerAreaRules,
|
||||
Long preSalesUserId,
|
||||
List<String> preSalesUserNames) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@
|
|||
o.owner_user_id as "ownerUserId",
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
|
||||
coalesce(o.project_location, '') as "projectLocation",
|
||||
coalesce(o.project_ownership_location, '') as "projectOwnershipLocation",
|
||||
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as "projectOwnershipLocationName",
|
||||
coalesce(o.product_type, '') as "productType",
|
||||
coalesce(o.source, '') as "source",
|
||||
o.amount as "amount",
|
||||
|
|
@ -119,6 +121,9 @@
|
|||
from crm_opportunity o
|
||||
left join crm_customer c on c.id = o.customer_id
|
||||
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
|
||||
left join cnarea project_ownership_area
|
||||
on project_ownership_area.level = 1
|
||||
and project_ownership_area.area_code = o.project_ownership_location
|
||||
where 1 = 1
|
||||
<if test="ownerUserId != null">
|
||||
and o.owner_user_id = #{ownerUserId}
|
||||
|
|
@ -141,6 +146,8 @@
|
|||
or coalesce(o.opportunity_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.customer_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_ownership_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(project_ownership_area.short_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.product_type, '') ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
</if>
|
||||
|
|
@ -188,13 +195,16 @@
|
|||
'opportunity' as "module",
|
||||
o.id as "id",
|
||||
coalesce(o.opportunity_name, '') as "title",
|
||||
concat_ws(' | ', o.opportunity_code, coalesce(c.customer_name, ''), o.stage, o.status, o.product_type, o.project_location, left(coalesce(o.description, ''), 300)) as "summary",
|
||||
concat_ws(' | ', o.opportunity_code, coalesce(c.customer_name, ''), o.stage, o.status, o.product_type, o.project_location, coalesce(project_ownership_area.short_name, o.project_ownership_location), left(coalesce(o.description, ''), 300)) as "summary",
|
||||
o.owner_user_id as "ownerUserId",
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
|
||||
coalesce(o.updated_at, o.created_at) as "sortTime"
|
||||
from crm_opportunity o
|
||||
left join crm_customer c on c.id = o.customer_id
|
||||
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
|
||||
left join cnarea project_ownership_area
|
||||
on project_ownership_area.level = 1
|
||||
and project_ownership_area.area_code = o.project_ownership_location
|
||||
where #{module} in ('all', 'opportunity')
|
||||
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
|
|
@ -205,6 +215,8 @@
|
|||
or coalesce(o.stage, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.product_type, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_ownership_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(project_ownership_area.short_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.description, '') ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
</if>
|
||||
|
|
@ -464,7 +476,7 @@
|
|||
'opportunity' as "module",
|
||||
o.id as "id",
|
||||
coalesce(o.opportunity_name, '') as "title",
|
||||
concat_ws(' | ', o.opportunity_code, coalesce(c.customer_name, ''), o.stage, o.status, o.product_type, o.project_location, left(coalesce(o.description, ''), 300)) as "summary",
|
||||
concat_ws(' | ', o.opportunity_code, coalesce(c.customer_name, ''), o.stage, o.status, o.product_type, o.project_location, coalesce(project_ownership_area.short_name, o.project_ownership_location), left(coalesce(o.description, ''), 300)) as "summary",
|
||||
o.owner_user_id as "ownerUserId",
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "ownerName",
|
||||
coalesce(o.updated_at, o.created_at) as "sortTime",
|
||||
|
|
@ -472,6 +484,9 @@
|
|||
from crm_opportunity o
|
||||
left join crm_customer c on c.id = o.customer_id
|
||||
left join sys_user u on u.user_id = o.owner_user_id and coalesce(u.is_deleted, 0) = 0
|
||||
left join cnarea project_ownership_area
|
||||
on project_ownership_area.level = 1
|
||||
and project_ownership_area.area_code = o.project_ownership_location
|
||||
where 1 = 1
|
||||
<if test="ownerUserId != null">and o.owner_user_id = #{ownerUserId}</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
|
|
@ -482,6 +497,8 @@
|
|||
or coalesce(o.stage, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.product_type, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_ownership_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(project_ownership_area.short_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.description, '') ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
</if>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,23 @@
|
|||
<sql id="opportunityVisibilityCondition">
|
||||
<if test="!allDataAccess">
|
||||
<trim prefix="and (" suffix=")" prefixOverrides="or">
|
||||
<if test="visibleOwnerUserIds != null and visibleOwnerUserIds.size() > 0">
|
||||
or o.owner_user_id in
|
||||
<foreach collection="visibleOwnerUserIds" item="ownerUserId" open="(" separator="," close=")">
|
||||
#{ownerUserId}
|
||||
<if test="visibleOwnerAreaRules != null and visibleOwnerAreaRules.size() > 0">
|
||||
or
|
||||
<foreach collection="visibleOwnerAreaRules" item="ownerRule" open="(" separator=" or " close=")">
|
||||
<choose>
|
||||
<when test="ownerRule.allAreas">
|
||||
o.owner_user_id = #{ownerRule.ownerUserId}
|
||||
</when>
|
||||
<otherwise>
|
||||
(
|
||||
o.owner_user_id = #{ownerRule.ownerUserId}
|
||||
and o.project_ownership_location in
|
||||
<foreach collection="ownerRule.areaCodes" item="areaCode" open="(" separator="," close=")">
|
||||
#{areaCode}
|
||||
</foreach>
|
||||
)
|
||||
</otherwise>
|
||||
</choose>
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="preSalesUserId != null">
|
||||
|
|
@ -22,7 +35,7 @@
|
|||
#{preSalesUserName}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="(visibleOwnerUserIds == null or visibleOwnerUserIds.size() == 0) and preSalesUserId == null and (preSalesUserNames == null or preSalesUserNames.size() == 0)">
|
||||
<if test="(visibleOwnerAreaRules == null or visibleOwnerAreaRules.size() == 0) and preSalesUserId == null and (preSalesUserNames == null or preSalesUserNames.size() == 0)">
|
||||
1 = 0
|
||||
</if>
|
||||
</trim>
|
||||
|
|
@ -49,6 +62,37 @@
|
|||
order by area_code asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectProjectOwnershipLocationOptions" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
|
||||
select
|
||||
short_name as label,
|
||||
area_code as value
|
||||
from cnarea
|
||||
where level = 1
|
||||
and coalesce(nullif(btrim(area_code), ''), '') <> ''
|
||||
and coalesce(nullif(btrim(short_name), ''), '') <> ''
|
||||
order by area_code asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectProvinceAreaCodeByAnyName" resultType="java.lang.String">
|
||||
select area_code
|
||||
from cnarea
|
||||
where level = 1
|
||||
and (
|
||||
area_code = #{value}
|
||||
or short_name = #{value}
|
||||
or name = #{value}
|
||||
)
|
||||
order by
|
||||
case
|
||||
when area_code = #{value} then 0
|
||||
when short_name = #{value} then 1
|
||||
else 2
|
||||
end,
|
||||
area_code asc,
|
||||
id asc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectDictLabel" resultType="java.lang.String">
|
||||
select item_label
|
||||
from sys_dict_item
|
||||
|
|
@ -91,6 +135,8 @@
|
|||
'无'
|
||||
) as updatedAt,
|
||||
coalesce(o.project_location, '') as projectLocation,
|
||||
coalesce(o.project_ownership_location, '') as projectOwnershipLocation,
|
||||
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName,
|
||||
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
|
||||
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
|
||||
o.amount,
|
||||
|
|
@ -186,6 +232,9 @@
|
|||
left join sys_user u on u.user_id = o.owner_user_id
|
||||
left join crm_sales_expansion se on se.id = o.sales_expansion_id
|
||||
left join crm_channel_expansion ce on ce.id = o.channel_expansion_id
|
||||
left join cnarea project_ownership_area
|
||||
on project_ownership_area.level = 1
|
||||
and project_ownership_area.area_code = o.project_ownership_location
|
||||
left join sys_dict_item stage_dict
|
||||
on stage_dict.type_code = 'sj_xmjd'
|
||||
and (
|
||||
|
|
@ -214,6 +263,7 @@
|
|||
or o.opportunity_code ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.customer_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.project_ownership_location, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.operator_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(operator_dict.item_label, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(o.pre_sales_name, '') ilike concat('%', #{keyword}, '%')
|
||||
|
|
@ -247,6 +297,8 @@
|
|||
'无'
|
||||
) as updatedAt,
|
||||
coalesce(o.project_location, '') as projectLocation,
|
||||
coalesce(o.project_ownership_location, '') as projectOwnershipLocation,
|
||||
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName,
|
||||
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
|
||||
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
|
||||
o.amount,
|
||||
|
|
@ -342,6 +394,9 @@
|
|||
left join sys_user u on u.user_id = o.owner_user_id
|
||||
left join crm_sales_expansion se on se.id = o.sales_expansion_id
|
||||
left join crm_channel_expansion ce on ce.id = o.channel_expansion_id
|
||||
left join cnarea project_ownership_area
|
||||
on project_ownership_area.level = 1
|
||||
and project_ownership_area.area_code = o.project_ownership_location
|
||||
left join sys_dict_item stage_dict
|
||||
on stage_dict.type_code = 'sj_xmjd'
|
||||
and (
|
||||
|
|
@ -446,6 +501,7 @@
|
|||
customer_id,
|
||||
owner_user_id,
|
||||
project_location,
|
||||
project_ownership_location,
|
||||
operator_name,
|
||||
amount,
|
||||
expected_close_date,
|
||||
|
|
@ -472,6 +528,7 @@
|
|||
#{customerId},
|
||||
#{userId},
|
||||
#{request.projectLocation},
|
||||
#{request.projectOwnershipLocation},
|
||||
#{request.operatorName},
|
||||
#{request.amount},
|
||||
#{request.expectedCloseDate},
|
||||
|
|
@ -599,6 +656,7 @@
|
|||
set opportunity_name = #{request.opportunityName},
|
||||
customer_id = #{customerId},
|
||||
project_location = #{request.projectLocation},
|
||||
project_ownership_location = #{request.projectOwnershipLocation},
|
||||
operator_name = #{request.operatorName},
|
||||
amount = #{request.amount},
|
||||
expected_close_date = #{request.expectedCloseDate},
|
||||
|
|
@ -648,6 +706,9 @@
|
|||
<if test="request.projectLocation != null">
|
||||
project_location = #{request.projectLocation},
|
||||
</if>
|
||||
<if test="request.projectOwnershipLocation != null">
|
||||
project_ownership_location = #{request.projectOwnershipLocation},
|
||||
</if>
|
||||
<if test="request.operatorName != null">
|
||||
operator_name = #{request.operatorName},
|
||||
</if>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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.argThat;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
|
@ -11,6 +12,7 @@ import static org.mockito.Mockito.when;
|
|||
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.dto.datascope.UserDataScopeAssignmentDTO;
|
||||
import com.unis.crm.dto.datascope.UserDataScopeOwnerRuleDTO;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import com.unisbase.security.SpringSecurityUserProvider;
|
||||
|
|
@ -70,6 +72,8 @@ class UserDataScopeAdminServiceTest {
|
|||
eq(9L),
|
||||
eq(2L),
|
||||
eq("OPPORTUNITY"),
|
||||
eq("ALL"),
|
||||
any(String[].class),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq("临时授权"),
|
||||
|
|
@ -80,6 +84,8 @@ class UserDataScopeAdminServiceTest {
|
|||
eq(9L),
|
||||
eq(3L),
|
||||
eq("OPPORTUNITY"),
|
||||
eq("ALL"),
|
||||
any(String[].class),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq("临时授权"),
|
||||
|
|
@ -143,6 +149,8 @@ class UserDataScopeAdminServiceTest {
|
|||
eq(9L),
|
||||
eq(2L),
|
||||
eq("OPPORTUNITY"),
|
||||
eq("ALL"),
|
||||
any(String[].class),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq(null),
|
||||
|
|
@ -153,6 +161,43 @@ class UserDataScopeAdminServiceTest {
|
|||
eq(9L),
|
||||
eq(2L),
|
||||
eq("EXPANSION"),
|
||||
eq("ALL"),
|
||||
any(String[].class),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq(null),
|
||||
eq(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAssignment_shouldPersistOpportunityAreaRules() {
|
||||
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);
|
||||
|
||||
UserDataScopeOwnerRuleDTO ownerRule = new UserDataScopeOwnerRuleDTO();
|
||||
ownerRule.setOwnerUserId(2L);
|
||||
ownerRule.setAreaScopeType("CUSTOM");
|
||||
ownerRule.setAreaCodes(List.of("110000", "310000"));
|
||||
UserDataScopeAssignmentDTO payload = new UserDataScopeAssignmentDTO();
|
||||
payload.setTenantId(100L);
|
||||
payload.setViewerUserId(9L);
|
||||
payload.setResourceTypes(List.of("OPPORTUNITY"));
|
||||
payload.setOwnerRules(List.of(ownerRule));
|
||||
|
||||
boolean result = userDataScopeAdminService.saveAssignment(payload);
|
||||
|
||||
assertEquals(true, result);
|
||||
verify(jdbcTemplate).update(
|
||||
contains("insert into sys_user_data_scope_user"),
|
||||
eq(100L),
|
||||
eq(9L),
|
||||
eq(2L),
|
||||
eq("OPPORTUNITY"),
|
||||
eq("CUSTOM"),
|
||||
argThat((String[] values) -> List.of(values).equals(List.of("110000", "310000"))),
|
||||
eq(true),
|
||||
eq(null),
|
||||
eq(null),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import static org.mockito.Mockito.when;
|
|||
|
||||
import com.unis.crm.service.CrmDataVisibilityService;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
|
||||
import com.unis.crm.service.CrmDataVisibilityService.OwnerAreaRule;
|
||||
import com.unisbase.dto.DataScopeRuleDTO;
|
||||
import com.unisbase.service.DataScopeService;
|
||||
import java.util.Arrays;
|
||||
|
|
@ -21,6 +22,7 @@ import org.mockito.InjectMocks;
|
|||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CrmDataVisibilityServiceImplTest {
|
||||
|
|
@ -39,19 +41,27 @@ class CrmDataVisibilityServiceImplTest {
|
|||
DataScopeRuleDTO rule = new DataScopeRuleDTO();
|
||||
rule.setCreatorUserIds(Arrays.asList(2L, 3L, 2L, null, -1L));
|
||||
when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule);
|
||||
when(jdbcTemplate.queryForList(
|
||||
when(jdbcTemplate.query(
|
||||
anyString(),
|
||||
eq(Long.class),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<OwnerAreaRule>>any(),
|
||||
eq(100L),
|
||||
eq(9L),
|
||||
eq(CrmDataVisibilityService.RESOURCE_ALL),
|
||||
eq(CrmDataVisibilityService.RESOURCE_OPPORTUNITY)))
|
||||
.thenReturn(List.of(3L, 4L, 5L));
|
||||
.thenReturn(List.of(
|
||||
new OwnerAreaRule(3L, false, List.of("110000")),
|
||||
new OwnerAreaRule(4L, true, List.of()),
|
||||
new OwnerAreaRule(5L, false, List.of("310000"))));
|
||||
|
||||
DataVisibility visibility = service.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY);
|
||||
|
||||
assertFalse(visibility.allDataAccess());
|
||||
assertEquals(List.of(2L, 3L, 4L, 5L), visibility.visibleOwnerUserIds());
|
||||
assertEquals(List.of(
|
||||
new OwnerAreaRule(2L, true, List.of()),
|
||||
new OwnerAreaRule(3L, true, List.of()),
|
||||
new OwnerAreaRule(4L, true, List.of()),
|
||||
new OwnerAreaRule(5L, false, List.of("310000"))), visibility.visibleOwnerAreaRules());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -64,6 +74,6 @@ class CrmDataVisibilityServiceImplTest {
|
|||
|
||||
assertTrue(visibility.allDataAccess());
|
||||
assertEquals(List.of(), visibility.visibleOwnerUserIds());
|
||||
verify(jdbcTemplate, never()).queryForList(anyString(), eq(Long.class), org.mockito.ArgumentMatchers.<Object>any());
|
||||
verify(jdbcTemplate, never()).query(anyString(), org.mockito.ArgumentMatchers.<RowMapper<OwnerAreaRule>>any(), org.mockito.ArgumentMatchers.<Object>any());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class ExpansionServiceImplTest {
|
|||
grantedItem.setName("授权拓展");
|
||||
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
|
||||
when(crmDataVisibilityService.resolveVisibility(29L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
|
||||
.thenReturn(new DataVisibility(false, List.of(29L, 36L)));
|
||||
.thenReturn(new DataVisibility(false, List.of(29L, 36L), List.of()));
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ 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.CrmDataVisibilityService.OwnerAreaRule;
|
||||
import com.unis.crm.service.OmsClient;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||
|
|
@ -218,8 +219,11 @@ class OpportunityServiceImplTest {
|
|||
@Test
|
||||
void getOverview_shouldKeepConfiguredDataScopeAndIncludePreSalesVisibility() {
|
||||
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
|
||||
List<OwnerAreaRule> ownerAreaRules = List.of(
|
||||
new OwnerAreaRule(2L, true, List.of()),
|
||||
new OwnerAreaRule(3L, false, List.of("110000", "310000")));
|
||||
when(crmDataVisibilityService.resolveVisibility(9L, 100L, CrmDataVisibilityService.RESOURCE_OPPORTUNITY))
|
||||
.thenReturn(new DataVisibility(false, List.of(2L, 3L, 2L)));
|
||||
.thenReturn(new DataVisibility(false, List.of(2L, 3L, 2L), ownerAreaRules));
|
||||
CurrentUserAccountDTO currentUser = new CurrentUserAccountDTO();
|
||||
currentUser.setUserId(9L);
|
||||
currentUser.setUsername("presales.zhang");
|
||||
|
|
@ -234,6 +238,7 @@ class OpportunityServiceImplTest {
|
|||
eq(null),
|
||||
eq(false),
|
||||
eq(List.of(2L, 3L)),
|
||||
eq(ownerAreaRules),
|
||||
eq(9L),
|
||||
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of(item));
|
||||
when(opportunityMapper.selectOpportunityFollowUps(
|
||||
|
|
@ -241,6 +246,7 @@ class OpportunityServiceImplTest {
|
|||
eq(List.of(10L)),
|
||||
eq(false),
|
||||
eq(List.of(2L, 3L)),
|
||||
eq(ownerAreaRules),
|
||||
eq(9L),
|
||||
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of());
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class WorkServiceImplTest {
|
|||
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)));
|
||||
.thenReturn(new DataVisibility(false, List.of(17L, 36L), List.of()));
|
||||
when(workMapper.selectReportHistory(3)).thenReturn(List.of(
|
||||
historyItem(21L, "日报", "2026-04-02", "09:15", "本人日报")));
|
||||
when(workMapper.selectReportHistoryByUserIds(List.of(36L), 3)).thenReturn(List.of(
|
||||
|
|
@ -431,7 +431,7 @@ class WorkServiceImplTest {
|
|||
.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)));
|
||||
.thenReturn(new DataVisibility(false, List.of(18L), List.of()));
|
||||
when(workMapper.selectCheckInExportRowsByUserIds(List.of(18L), null, null, null, null, null, null, 5000))
|
||||
.thenReturn(List.of(duplicate, granted));
|
||||
|
||||
|
|
@ -505,7 +505,7 @@ class WorkServiceImplTest {
|
|||
.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)));
|
||||
.thenReturn(new DataVisibility(false, List.of(18L), List.of()));
|
||||
when(workMapper.selectDailyReportExportRowsByUserIds(List.of(18L), null, null, null, null, null, 5000))
|
||||
.thenReturn(List.of(duplicate, granted));
|
||||
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ OMS返回成功,但未返回项目编号
|
|||
| --- | --- | --- |
|
||||
| `opportunityName` | string | 商机名称,最大 200 字符。 |
|
||||
| `projectLocation` | string | 项目地,最大 100 字符。 |
|
||||
| `projectOwnershipLocation` | string | 业绩归属地编码,对应 `cnarea.area_code`;页面显示 `cnarea.short_name`。 |
|
||||
| `operatorName` | string | 运作方,最大 100 字符。 |
|
||||
| `amount` | number | 预计金额;历史数据回写不再限制必须大于 0。 |
|
||||
| `actualSignedAmount` | number | 实际签约金额;兼容字段名 `actual_signed_amount`。 |
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
| -------------------- | ------- | -------------------------------------- |
|
||||
| `opportunityName` | string | 商机名称 |
|
||||
| `projectLocation` | string | 项目地 |
|
||||
| `projectOwnershipLocation` | string | 业绩归属地编码,对应 `cnarea.area_code` |
|
||||
| `operatorName` | string | 运作方 |
|
||||
| `amount` | number | 商机金额,历史数据回写不再限制必须大于 0 |
|
||||
| `actualSignedAmount` | number | 实际签约金额,兼容字段名 `actual_signed_amount` |
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ erDiagram
|
|||
| `pre_sales_id` | bigint | 售前 ID |
|
||||
| `pre_sales_name` | varchar(100) | 售前姓名 |
|
||||
| `project_location` | varchar(100) | 项目所在地 |
|
||||
| `project_ownership_location` | varchar(100) | 业绩归属地编码,对应 `cnarea.area_code` |
|
||||
| `operator_name` | varchar(100) | 运作方 |
|
||||
| `amount` | numeric(18,2) | 商机金额 |
|
||||
| `expected_close_date` | date | 预计结单日期 |
|
||||
|
|
|
|||
|
|
@ -473,6 +473,8 @@ export interface OpportunityItem {
|
|||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
projectLocation?: string;
|
||||
projectOwnershipLocation?: string;
|
||||
projectOwnershipLocationName?: string;
|
||||
operatorCode?: string;
|
||||
operatorName?: string;
|
||||
amount?: number;
|
||||
|
|
@ -517,6 +519,7 @@ export interface OpportunityMeta {
|
|||
stageOptions?: OpportunityDictOption[];
|
||||
operatorOptions?: OpportunityDictOption[];
|
||||
projectLocationOptions?: OpportunityDictOption[];
|
||||
projectOwnershipLocationOptions?: OpportunityDictOption[];
|
||||
opportunityTypeOptions?: OpportunityDictOption[];
|
||||
confidenceOptions?: OpportunityDictOption[];
|
||||
sysIsOptions?: OpportunityDictOption[];
|
||||
|
|
@ -532,6 +535,7 @@ export interface CreateOpportunityPayload {
|
|||
opportunityName: string;
|
||||
customerName: string;
|
||||
projectLocation?: string;
|
||||
projectOwnershipLocation?: string;
|
||||
operatorName?: string;
|
||||
amount: number;
|
||||
expectedCloseDate: string;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ type OpportunityExportColumn = {
|
|||
};
|
||||
type OpportunityField =
|
||||
| "projectLocation"
|
||||
| "projectOwnershipLocation"
|
||||
| "opportunityName"
|
||||
| "customerName"
|
||||
| "operatorName"
|
||||
|
|
@ -166,6 +167,7 @@ const defaultForm: CreateOpportunityPayload = {
|
|||
opportunityName: "",
|
||||
customerName: "",
|
||||
projectLocation: "",
|
||||
projectOwnershipLocation: "",
|
||||
operatorName: "",
|
||||
amount: 0,
|
||||
expectedCloseDate: "",
|
||||
|
|
@ -494,6 +496,8 @@ function matchesOpportunityExportFilters(
|
|||
item.client,
|
||||
item.owner,
|
||||
item.projectLocation,
|
||||
item.projectOwnershipLocationName,
|
||||
item.projectOwnershipLocation,
|
||||
item.operatorName,
|
||||
item.stage,
|
||||
item.type,
|
||||
|
|
@ -547,6 +551,7 @@ function toFormFromItem(item: OpportunityItem, confidenceOptions: OpportunityDic
|
|||
opportunityName: item.name || "",
|
||||
customerName: item.client || "",
|
||||
projectLocation: item.projectLocation || "",
|
||||
projectOwnershipLocation: item.projectOwnershipLocation || "",
|
||||
amount: item.amount || 0,
|
||||
expectedCloseDate: item.date || "",
|
||||
confidencePct: normalizeConfidenceValue(item.confidence, confidenceOptions),
|
||||
|
|
@ -1897,6 +1902,7 @@ export default function Opportunities() {
|
|||
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [projectOwnershipLocationOptions, setProjectOwnershipLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [sysIsOptions, setSysIsOptions] = useState<OpportunityDictOption[]>([]);
|
||||
|
|
@ -2156,6 +2162,7 @@ export default function Opportunities() {
|
|||
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
|
||||
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
|
||||
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
||||
setProjectOwnershipLocationOptions((data.projectOwnershipLocationOptions ?? []).filter((item) => item.value));
|
||||
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
|
||||
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
|
||||
setSysIsOptions((data.sysIsOptions ?? []).filter((item) => item.value));
|
||||
|
|
@ -2165,6 +2172,7 @@ export default function Opportunities() {
|
|||
setStageOptions([]);
|
||||
setOperatorOptions([]);
|
||||
setProjectLocationOptions([]);
|
||||
setProjectOwnershipLocationOptions([]);
|
||||
setOpportunityTypeOptions([]);
|
||||
setConfidenceOptions([]);
|
||||
setSysIsOptions([]);
|
||||
|
|
@ -2299,6 +2307,7 @@ export default function Opportunities() {
|
|||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||
].filter((item) => item.value);
|
||||
const normalizedProjectLocation = form.projectLocation?.trim() || "";
|
||||
const normalizedProjectOwnershipLocation = form.projectOwnershipLocation?.trim() || "";
|
||||
const projectLocationSelectOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
...projectLocationOptions.map((item) => ({
|
||||
|
|
@ -2312,6 +2321,19 @@ export default function Opportunities() {
|
|||
: []
|
||||
),
|
||||
];
|
||||
const projectOwnershipLocationSelectOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
...projectOwnershipLocationOptions.map((item) => ({
|
||||
label: item.label || item.value || "",
|
||||
value: item.value || "",
|
||||
})),
|
||||
...(
|
||||
normalizedProjectOwnershipLocation
|
||||
&& !projectOwnershipLocationOptions.some((item) => (item.value || "").trim() === normalizedProjectOwnershipLocation)
|
||||
? [{ value: normalizedProjectOwnershipLocation, label: normalizedProjectOwnershipLocation }]
|
||||
: []
|
||||
),
|
||||
];
|
||||
const normalizedOpportunityType = form.opportunityType?.trim() || "";
|
||||
const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0
|
||||
? opportunityTypeOptions
|
||||
|
|
@ -3187,6 +3209,20 @@ export default function Opportunities() {
|
|||
/>
|
||||
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">业绩归属地</span>
|
||||
<AdaptiveSelect
|
||||
value={form.projectOwnershipLocation || ""}
|
||||
placeholder="请选择"
|
||||
sheetTitle="业绩归属地"
|
||||
searchable
|
||||
searchPlaceholder="搜索业绩归属地"
|
||||
options={projectOwnershipLocationSelectOptions}
|
||||
className={getFieldInputClass(Boolean(fieldErrors.projectOwnershipLocation))}
|
||||
onChange={(value) => handleChange("projectOwnershipLocation", value)}
|
||||
/>
|
||||
{fieldErrors.projectOwnershipLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectOwnershipLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目名称<RequiredMark /></span>
|
||||
<input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.opportunityName))} />
|
||||
|
|
@ -3575,6 +3611,7 @@ export default function Opportunities() {
|
|||
) : null}
|
||||
<div className="crm-detail-grid text-sm md:grid-cols-2">
|
||||
<DetailItem label="项目地" value={detailItem.projectLocation || "无"} />
|
||||
<DetailItem label="业绩归属地" value={detailItem.projectOwnershipLocationName || detailItem.projectOwnershipLocation || "无"} />
|
||||
<DetailItem label="最终用户" value={detailItem.client || "无"} icon={<Building className="h-3 w-3" />} />
|
||||
<DetailItem label="运作方" value={detailItem.operatorName || "无"} />
|
||||
{hasSelectedChannelExpansion ? (
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ type QuickCreateType = "sales" | "channel" | "opportunity";
|
|||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||
type OpportunityQuickField =
|
||||
| "projectLocation"
|
||||
| "projectOwnershipLocation"
|
||||
| "opportunityName"
|
||||
| "customerName"
|
||||
| "operatorName"
|
||||
|
|
@ -192,6 +193,7 @@ const defaultQuickOpportunityForm: CreateOpportunityPayload = {
|
|||
opportunityName: "",
|
||||
customerName: "",
|
||||
projectLocation: "",
|
||||
projectOwnershipLocation: "",
|
||||
operatorName: "",
|
||||
amount: 0,
|
||||
expectedCloseDate: "",
|
||||
|
|
@ -864,6 +866,7 @@ export default function Work() {
|
|||
const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [quickOpportunityOperatorOptions, setQuickOpportunityOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [quickOpportunityProjectLocationOptions, setQuickOpportunityProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [quickOpportunityProjectOwnershipLocationOptions, setQuickOpportunityProjectOwnershipLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [quickOpportunityTypeOptions, setQuickOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [quickOpportunityStageOptions, setQuickOpportunityStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [quickOpportunityConfidenceOptions, setQuickOpportunityConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
||||
|
|
@ -1543,6 +1546,7 @@ export default function Work() {
|
|||
setQuickInternalAttributeOptions(expansionMeta.internalAttributeOptions ?? []);
|
||||
setQuickOpportunityOperatorOptions((opportunityMeta.operatorOptions ?? []).filter((item) => item.value));
|
||||
setQuickOpportunityProjectLocationOptions((opportunityMeta.projectLocationOptions ?? []).filter((item) => item.value));
|
||||
setQuickOpportunityProjectOwnershipLocationOptions((opportunityMeta.projectOwnershipLocationOptions ?? []).filter((item) => item.value));
|
||||
setQuickOpportunityTypeOptions((opportunityMeta.opportunityTypeOptions ?? []).filter((item) => item.value));
|
||||
setQuickOpportunityStageOptions((opportunityMeta.stageOptions ?? []).filter((item) => item.value));
|
||||
setQuickOpportunityConfidenceOptions((opportunityMeta.confidenceOptions ?? []).filter((item) => item.value));
|
||||
|
|
@ -2683,6 +2687,26 @@ export default function Work() {
|
|||
/>
|
||||
{quickOpportunityFieldErrors.projectLocation ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.projectLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">业绩归属地</span>
|
||||
<AdaptiveSelect
|
||||
value={quickOpportunityForm.projectOwnershipLocation || ""}
|
||||
placeholder="请选择"
|
||||
sheetTitle="业绩归属地"
|
||||
searchable
|
||||
searchPlaceholder="搜索业绩归属地"
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...quickOpportunityProjectOwnershipLocationOptions.map((item) => ({
|
||||
value: item.value || "",
|
||||
label: item.label || item.value || "",
|
||||
})),
|
||||
]}
|
||||
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.projectOwnershipLocation))}
|
||||
onChange={(value) => handleQuickOpportunityChange("projectOwnershipLocation", value)}
|
||||
/>
|
||||
{quickOpportunityFieldErrors.projectOwnershipLocation ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.projectOwnershipLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目名称<RequiredMark /></span>
|
||||
<input value={quickOpportunityForm.opportunityName} onChange={(e) => handleQuickOpportunityChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.opportunityName))} />
|
||||
|
|
|
|||
|
|
@ -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-BzRsHhy3.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BaO1tuFT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import http from "./http";
|
||||
import {
|
||||
BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
|
||||
PermissionNode, UserDataScopeAssignment, UserDataScopeGrant, UserDataScopeUser
|
||||
PermissionNode, UserDataScopeAssignment, UserDataScopeGrant, UserDataScopeUser, OpportunityDictOption
|
||||
} from "../types";
|
||||
|
||||
export async function pageParams(params: SysParamQuery) {
|
||||
|
|
@ -224,6 +224,11 @@ export async function deleteUserDataScopeGrant(id: number, params?: { tenantId?:
|
|||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function listUserDataScopeProjectOwnershipLocations(params?: { tenantId?: number }) {
|
||||
const resp = await http.get("/sys/api/admin/user-data-scope/project-ownership-locations", { params });
|
||||
return resp.data.data as OpportunityDictOption[];
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: any) {
|
||||
const resp = await http.get("/sys/api/logs", { params });
|
||||
return resp.data.data;
|
||||
|
|
|
|||
|
|
@ -84,8 +84,71 @@
|
|||
}
|
||||
}
|
||||
|
||||
.user-data-scope-area-panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-data-scope-area-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rule {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) 132px minmax(220px, 2fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rule__owner {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rule__scope,
|
||||
.user-data-scope-area-rule__areas {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rule__all {
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 5px 11px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.user-data-scope-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-data-scope-area-panel__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-data-scope-area-rule {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { App, Button, Card, Col, DatePicker, Drawer, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { App, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, Popconfirm, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import { deleteUserDataScopeGrant, fetchUsersByRoleId, listRoles, listTenants, listUserDataScopeGrants, listUserDataScopeUsers, saveUserDataScopeAssignment } from "@/api";
|
||||
import { deleteUserDataScopeGrant, fetchUsersByRoleId, listRoles, listTenants, listUserDataScopeGrants, listUserDataScopeProjectOwnershipLocations, listUserDataScopeUsers, saveUserDataScopeAssignment } from "@/api";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysRole, SysTenant, UserDataScopeGrant, UserDataScopeUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
type AreaScopeType = "ALL" | "CUSTOM";
|
||||
|
||||
interface OwnerAreaRuleFormValue {
|
||||
ownerUserId: number;
|
||||
areaScopeType: AreaScopeType;
|
||||
areaCodes: string[];
|
||||
}
|
||||
|
||||
const resourceOptions = [
|
||||
{ label: "商机数据", value: "OPPORTUNITY" },
|
||||
|
|
@ -36,6 +43,7 @@ interface UserDataScopeGrantGroup {
|
|||
viewerUserId: number;
|
||||
viewerName: string;
|
||||
ownerUsers: Array<{ userId: number; name: string }>;
|
||||
ownerRules: Array<{ ownerUserId: number; ownerName: string; areaScopeType: AreaScopeType; areaCodes: string[]; areaNames: string[] }>;
|
||||
resourceTypes: string[];
|
||||
enabled: boolean;
|
||||
expireAt?: string;
|
||||
|
|
@ -76,6 +84,22 @@ function formatExpireAt(value?: string) {
|
|||
return value ? value.replace("T", " ").slice(0, 16) : "永久";
|
||||
}
|
||||
|
||||
function normalizeAreaScopeType(value?: string): AreaScopeType {
|
||||
return value === "CUSTOM" ? "CUSTOM" : "ALL";
|
||||
}
|
||||
|
||||
function areaSummaryFromRules(rules: UserDataScopeGrantGroup["ownerRules"]) {
|
||||
if (!rules.length) {
|
||||
return "全部归属地";
|
||||
}
|
||||
const customRules = rules.filter((rule) => rule.areaScopeType === "CUSTOM");
|
||||
if (!customRules.length) {
|
||||
return "全部归属地";
|
||||
}
|
||||
const areaNames = Array.from(new Set(customRules.flatMap((rule) => rule.areaNames.length ? rule.areaNames : rule.areaCodes)));
|
||||
return areaNames.length ? areaNames.join("、") : "指定归属地";
|
||||
}
|
||||
|
||||
function buildGrantGroups(source: UserDataScopeGrant[]) {
|
||||
const buckets = new Map<string, Map<string, UserDataScopeGrant[]>>();
|
||||
for (const grant of source) {
|
||||
|
|
@ -98,16 +122,30 @@ function buildGrantGroups(source: UserDataScopeGrant[]) {
|
|||
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(",");
|
||||
.map((grant) => [
|
||||
grant.ownerUserId,
|
||||
normalizeAreaScopeType(grant.areaScopeType),
|
||||
[...(grant.areaCodes || [])].sort().join(",")
|
||||
].join(":"))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("|");
|
||||
ownerSetMap.set(ownerKey, [...(ownerSetMap.get(ownerKey) || []), ...resourceGrants]);
|
||||
}
|
||||
|
||||
for (const [ownerKey, groupGrants] of ownerSetMap) {
|
||||
const first = groupGrants[0];
|
||||
const ownerRules = Array.from(new Map(groupGrants.map((grant) => [
|
||||
`${grant.ownerUserId}|${grant.resourceType}`,
|
||||
{
|
||||
ownerUserId: grant.ownerUserId,
|
||||
ownerName: grant.ownerName,
|
||||
areaScopeType: normalizeAreaScopeType(grant.areaScopeType),
|
||||
areaCodes: grant.areaCodes || [],
|
||||
areaNames: grant.areaNames || []
|
||||
}
|
||||
])).values()).sort((a, b) => a.ownerName.localeCompare(b.ownerName, "zh-CN"));
|
||||
const ownerUsers = Array.from(
|
||||
new Map(groupGrants.map((grant) => [grant.ownerUserId, { userId: grant.ownerUserId, name: grant.ownerName }])).values()
|
||||
new Map(ownerRules.map((rule) => [rule.ownerUserId, { userId: rule.ownerUserId, name: rule.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"));
|
||||
|
|
@ -119,6 +157,7 @@ function buildGrantGroups(source: UserDataScopeGrant[]) {
|
|||
viewerUserId: first.viewerUserId,
|
||||
viewerName: first.viewerName,
|
||||
ownerUsers,
|
||||
ownerRules,
|
||||
resourceTypes,
|
||||
enabled: first.enabled,
|
||||
expireAt: first.expireAt,
|
||||
|
|
@ -146,6 +185,7 @@ export default function UserDataScopePage() {
|
|||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [grants, setGrants] = useState<UserDataScopeGrant[]>([]);
|
||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||
const [projectOwnershipLocationOptions, setProjectOwnershipLocationOptions] = useState<Array<{ label: string; value: string }>>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingGrantGroup, setEditingGrantGroup] = useState<UserDataScopeGrantGroup | null>(null);
|
||||
const [tableScrollY, setTableScrollY] = useState(360);
|
||||
|
|
@ -192,14 +232,23 @@ export default function UserDataScopePage() {
|
|||
setGrants(list || []);
|
||||
}, [selectedTenantId]);
|
||||
|
||||
const loadOpportunityMeta = useCallback(async () => {
|
||||
if (!selectedTenantId) {
|
||||
setProjectOwnershipLocationOptions([]);
|
||||
return;
|
||||
}
|
||||
const options = await listUserDataScopeProjectOwnershipLocations({ tenantId: selectedTenantId });
|
||||
setProjectOwnershipLocationOptions((options || []).filter((item) => item.value));
|
||||
}, [selectedTenantId]);
|
||||
|
||||
const loadPageData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([loadTenants(), loadUsers(), loadRoles(), loadGrants()]);
|
||||
await Promise.all([loadTenants(), loadUsers(), loadRoles(), loadGrants(), loadOpportunityMeta()]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadTenants, loadUsers, loadRoles, loadGrants]);
|
||||
}, [loadTenants, loadUsers, loadRoles, loadGrants, loadOpportunityMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPageData();
|
||||
|
|
@ -236,6 +285,7 @@ export default function UserDataScopePage() {
|
|||
group.remark,
|
||||
String(group.viewerUserId),
|
||||
...group.ownerUsers.flatMap((owner) => [owner.name, String(owner.userId)]),
|
||||
...group.ownerRules.flatMap((rule) => [rule.ownerName, areaSummaryFromRules([rule]), ...(rule.areaCodes || []), ...(rule.areaNames || [])]),
|
||||
...group.resourceTypes.flatMap((type) => [type, resourceLabel(type)])
|
||||
].filter(Boolean).join(" ").toLowerCase().includes(normalizedKeyword);
|
||||
});
|
||||
|
|
@ -293,6 +343,10 @@ export default function UserDataScopePage() {
|
|||
}, [filteredGrantGroups.length, query.current, query.pageSize]);
|
||||
|
||||
const selectedDrawerViewerId = Form.useWatch("viewerUserId", form);
|
||||
const selectedResourceTypes = Form.useWatch("resourceTypes", form) || [];
|
||||
const selectedOwnerUserIds = Form.useWatch("ownerUserIds", form) || [];
|
||||
const ownerAreaRules = Form.useWatch("ownerAreaRules", form) || [];
|
||||
const hasOpportunityResource = selectedResourceTypes.includes("OPPORTUNITY");
|
||||
const ownerOptions = useMemo(
|
||||
() => userOptions.filter((option) => option.value !== selectedDrawerViewerId),
|
||||
[selectedDrawerViewerId, userOptions]
|
||||
|
|
@ -313,6 +367,41 @@ export default function UserDataScopePage() {
|
|||
};
|
||||
}), [roles]);
|
||||
|
||||
const ownerNameMap = useMemo(() => new Map(users.map((user) => [user.userId, user.displayName || user.username || String(user.userId)])), [users]);
|
||||
|
||||
const syncOwnerAreaRules = useCallback((ownerUserIds: number[], existingRules?: OwnerAreaRuleFormValue[]) => {
|
||||
const previousRuleMap = new Map((existingRules || form.getFieldValue("ownerAreaRules") || []).map((rule: OwnerAreaRuleFormValue) => [rule.ownerUserId, rule]));
|
||||
const nextRules = ownerUserIds.map((ownerUserId) => {
|
||||
const previous = previousRuleMap.get(ownerUserId);
|
||||
return {
|
||||
ownerUserId,
|
||||
areaScopeType: previous?.areaScopeType || "ALL",
|
||||
areaCodes: previous?.areaCodes || []
|
||||
};
|
||||
});
|
||||
form.setFieldValue("ownerAreaRules", nextRules);
|
||||
}, [form]);
|
||||
|
||||
const handleOwnerUserChange = (ownerUserIds: number[]) => {
|
||||
form.setFieldValue("ownerUserIds", ownerUserIds);
|
||||
syncOwnerAreaRules(ownerUserIds);
|
||||
};
|
||||
|
||||
const applyAreaRuleToAllOwners = () => {
|
||||
const ownerUserIds = form.getFieldValue("ownerUserIds") || [];
|
||||
const areaScopeType = normalizeAreaScopeType(form.getFieldValue("bulkAreaScopeType"));
|
||||
const areaCodes = form.getFieldValue("bulkAreaCodes") || [];
|
||||
if (areaScopeType === "CUSTOM" && areaCodes.length === 0) {
|
||||
message.warning("请选择要批量应用的项目归属地");
|
||||
return;
|
||||
}
|
||||
form.setFieldValue("ownerAreaRules", ownerUserIds.map((ownerUserId: number) => ({
|
||||
ownerUserId,
|
||||
areaScopeType,
|
||||
areaCodes: areaScopeType === "CUSTOM" ? areaCodes : []
|
||||
})));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingGrantGroup(null);
|
||||
form.resetFields();
|
||||
|
|
@ -320,7 +409,10 @@ export default function UserDataScopePage() {
|
|||
resourceTypes: ["OPPORTUNITY"],
|
||||
enabled: true,
|
||||
roleIds: [],
|
||||
ownerUserIds: []
|
||||
ownerUserIds: [],
|
||||
bulkAreaScopeType: "ALL",
|
||||
bulkAreaCodes: [],
|
||||
ownerAreaRules: []
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
|
@ -333,6 +425,13 @@ export default function UserDataScopePage() {
|
|||
resourceTypes: record.resourceTypes,
|
||||
roleIds: [],
|
||||
ownerUserIds: record.ownerUsers.map((owner) => owner.userId),
|
||||
bulkAreaScopeType: "ALL",
|
||||
bulkAreaCodes: [],
|
||||
ownerAreaRules: record.ownerRules.map((rule) => ({
|
||||
ownerUserId: rule.ownerUserId,
|
||||
areaScopeType: rule.areaScopeType,
|
||||
areaCodes: rule.areaCodes
|
||||
})),
|
||||
enabled: record.enabled,
|
||||
expireAt: record.expireAt ? dayjs(record.expireAt) : undefined,
|
||||
remark: record.remark
|
||||
|
|
@ -360,7 +459,9 @@ export default function UserDataScopePage() {
|
|||
selectedOwnerIds.add(user.userId);
|
||||
}
|
||||
});
|
||||
form.setFieldValue("ownerUserIds", Array.from(selectedOwnerIds));
|
||||
const nextOwnerIds = Array.from(selectedOwnerIds);
|
||||
form.setFieldValue("ownerUserIds", nextOwnerIds);
|
||||
syncOwnerAreaRules(nextOwnerIds);
|
||||
} finally {
|
||||
setRoleUserLoading(false);
|
||||
}
|
||||
|
|
@ -373,6 +474,14 @@ export default function UserDataScopePage() {
|
|||
}
|
||||
const values = await form.validateFields();
|
||||
const resourceTypes = values.resourceTypes || [];
|
||||
const ownerRules = (values.ownerAreaRules || []).filter((rule: OwnerAreaRuleFormValue) => values.ownerUserIds?.includes(rule.ownerUserId));
|
||||
if (resourceTypes.includes("OPPORTUNITY")) {
|
||||
const invalidRule = ownerRules.find((rule: OwnerAreaRuleFormValue) => rule.areaScopeType === "CUSTOM" && (!rule.areaCodes || rule.areaCodes.length === 0));
|
||||
if (invalidRule) {
|
||||
message.warning("指定项目归属地时,请为每个可见人员选择至少一个归属地");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingGrantGroup) {
|
||||
|
|
@ -388,6 +497,11 @@ export default function UserDataScopePage() {
|
|||
resourceType: resourceTypes[0],
|
||||
resourceTypes,
|
||||
ownerUserIds: values.ownerUserIds || [],
|
||||
ownerRules: ownerRules.map((rule: OwnerAreaRuleFormValue) => ({
|
||||
ownerUserId: rule.ownerUserId,
|
||||
areaScopeType: resourceTypes.includes("OPPORTUNITY") ? rule.areaScopeType : "ALL",
|
||||
areaCodes: resourceTypes.includes("OPPORTUNITY") && rule.areaScopeType === "CUSTOM" ? rule.areaCodes : []
|
||||
})),
|
||||
enabled: values.enabled !== false,
|
||||
expireAt: values.expireAt ? values.expireAt.toISOString() : undefined,
|
||||
remark: values.remark
|
||||
|
|
@ -445,6 +559,32 @@ export default function UserDataScopePage() {
|
|||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "商机项目归属地",
|
||||
dataIndex: "ownerRules",
|
||||
width: 260,
|
||||
render: (_: unknown, record) => {
|
||||
if (!record.resourceTypes.includes("OPPORTUNITY")) {
|
||||
return <Text type="secondary">不限制</Text>;
|
||||
}
|
||||
const allArea = record.ownerRules.every((rule) => rule.areaScopeType !== "CUSTOM");
|
||||
if (allArea) {
|
||||
return <Tag color="green">全部归属地</Tag>;
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{record.ownerRules.map((rule) => (
|
||||
<Space key={rule.ownerUserId} wrap size={[4, 4]}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{rule.ownerName}</Text>
|
||||
{rule.areaScopeType === "CUSTOM"
|
||||
? (rule.areaNames.length ? rule.areaNames : rule.areaCodes).map((area) => <Tag key={`${rule.ownerUserId}-${area}`}>{area}</Tag>)
|
||||
: <Tag color="green">全部</Tag>}
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "enabled",
|
||||
|
|
@ -567,7 +707,7 @@ export default function UserDataScopePage() {
|
|||
columns={columns}
|
||||
dataSource={filteredGrantGroups}
|
||||
size="middle"
|
||||
scroll={{ y: tableScrollY, x: 980 }}
|
||||
scroll={{ y: tableScrollY, x: 1240 }}
|
||||
pagination={getStandardPagination(
|
||||
filteredGrantGroups.length,
|
||||
query.current,
|
||||
|
|
@ -581,11 +721,11 @@ export default function UserDataScopePage() {
|
|||
title={<span><TeamOutlined className="mr-2" />{editingGrantGroup ? "编辑数据授权" : "新建数据授权"}</span>}
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
width={560}
|
||||
width={720}
|
||||
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 form={form} layout="vertical" initialValues={{ resourceTypes: ["OPPORTUNITY"], enabled: true, ownerUserIds: [], bulkAreaScopeType: "ALL", bulkAreaCodes: [], ownerAreaRules: [] }}>
|
||||
<Form.Item label="查看人" name="viewerUserId" rules={[{ required: true, message: "请选择查看人" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
|
|
@ -623,8 +763,109 @@ export default function UserDataScopePage() {
|
|||
optionFilterProp="searchText"
|
||||
optionLabelProp="displayLabel"
|
||||
maxTagCount="responsive"
|
||||
onChange={handleOwnerUserChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasOpportunityResource && (
|
||||
<div className="user-data-scope-area-panel">
|
||||
<div className="user-data-scope-area-panel__header">
|
||||
<Text strong>商机项目归属地</Text>
|
||||
<Text type="secondary">未授权的归属地不可见</Text>
|
||||
</div>
|
||||
<Row gutter={12} align="bottom">
|
||||
<Col span={8}>
|
||||
<Form.Item label="批量范围" name="bulkAreaScopeType">
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: "全部", value: "ALL" },
|
||||
{ label: "指定", value: "CUSTOM" }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.bulkAreaScopeType !== next.bulkAreaScopeType}>
|
||||
{({ getFieldValue }) => getFieldValue("bulkAreaScopeType") === "CUSTOM" ? (
|
||||
<Form.Item label="批量归属地" name="bulkAreaCodes">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="选择项目归属地"
|
||||
options={projectOwnershipLocationOptions}
|
||||
optionFilterProp="label"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : <Form.Item label="批量归属地"><Input disabled value="全部归属地" /></Form.Item>}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Form.Item label=" ">
|
||||
<Button block onClick={applyAreaRuleToAllOwners}>应用到人员</Button>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{ margin: "8px 0 12px" }} />
|
||||
{selectedOwnerUserIds.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择可见人员后设置归属地" />
|
||||
) : (
|
||||
<Form.List name="ownerAreaRules">
|
||||
{(fields) => (
|
||||
<div className="user-data-scope-area-rules">
|
||||
{fields.map((field) => {
|
||||
const rule = ownerAreaRules[field.name] as OwnerAreaRuleFormValue | undefined;
|
||||
if (!rule || !selectedOwnerUserIds.includes(rule.ownerUserId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="user-data-scope-area-rule" key={field.key}>
|
||||
<div className="user-data-scope-area-rule__owner">
|
||||
<Text strong>{ownerNameMap.get(rule.ownerUserId) || rule.ownerUserId}</Text>
|
||||
<Text type="secondary">ID: {rule.ownerUserId}</Text>
|
||||
</div>
|
||||
<Form.Item name={[field.name, "areaScopeType"]} className="user-data-scope-area-rule__scope">
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: "全部", value: "ALL" },
|
||||
{ label: "指定", value: "CUSTOM" }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.ownerAreaRules?.[field.name]?.areaScopeType !== next.ownerAreaRules?.[field.name]?.areaScopeType}>
|
||||
{({ getFieldValue }) => getFieldValue(["ownerAreaRules", field.name, "areaScopeType"]) === "CUSTOM" ? (
|
||||
<Form.Item
|
||||
name={[field.name, "areaCodes"]}
|
||||
className="user-data-scope-area-rule__areas"
|
||||
rules={[{ required: true, message: "请选择归属地" }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="选择该人员可见归属地"
|
||||
options={projectOwnershipLocationOptions}
|
||||
optionFilterProp="label"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="user-data-scope-area-rule__all">全部归属地</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="启用状态" name="enabled">
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ export interface UserDataScopeGrant {
|
|||
ownerUserId: number;
|
||||
ownerName: string;
|
||||
resourceType: string;
|
||||
areaScopeType?: "ALL" | "CUSTOM";
|
||||
areaCodes?: string[];
|
||||
areaNames?: string[];
|
||||
enabled: boolean;
|
||||
expireAt?: string;
|
||||
remark?: string;
|
||||
|
|
@ -151,11 +154,31 @@ export interface UserDataScopeAssignment {
|
|||
resourceType?: string;
|
||||
resourceTypes?: string[];
|
||||
ownerUserIds: number[];
|
||||
ownerRules?: UserDataScopeOwnerRule[];
|
||||
areaScopeType?: "ALL" | "CUSTOM";
|
||||
areaCodes?: string[];
|
||||
expireAt?: string;
|
||||
remark?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UserDataScopeOwnerRule {
|
||||
ownerUserId: number;
|
||||
ownerName?: string;
|
||||
areaScopeType?: "ALL" | "CUSTOM";
|
||||
areaCodes?: string[];
|
||||
areaNames?: string[];
|
||||
}
|
||||
|
||||
export interface OpportunityDictOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface OpportunityMeta {
|
||||
projectOwnershipLocationOptions?: OpportunityDictOption[];
|
||||
}
|
||||
|
||||
export interface SysLog {
|
||||
id: number;
|
||||
tenantId?: number;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,234 @@
|
|||
-- 2026-07-02 商机业绩归属地数据回填脚本
|
||||
--
|
||||
-- 回填规则:
|
||||
-- 1. 党旭、杨斯雷:业绩归属地取商机项目地。
|
||||
-- - 项目地为省级名称/简称/编码时,写入该省级 cnarea.area_code。
|
||||
-- - 项目地为城市名称/简称/编码时,写入该城市上级省份 cnarea.area_code。
|
||||
-- 2. 其余表格内销售:业绩归属地取表格“主区域”,写入对应省级 cnarea.area_code。
|
||||
|
||||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
alter table if exists crm_opportunity
|
||||
add column if not exists project_ownership_location varchar(100);
|
||||
|
||||
comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code';
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_user_pk text;
|
||||
v_name_queries text[] := array[]::text[];
|
||||
v_main_region_updated integer := 0;
|
||||
v_project_location_updated integer := 0;
|
||||
begin
|
||||
if to_regclass('public.crm_opportunity') is null then
|
||||
raise exception 'crm_opportunity 表不存在,无法回填业绩归属地';
|
||||
end if;
|
||||
|
||||
if to_regclass('public.sys_user') is null then
|
||||
raise exception 'sys_user 表不存在,无法按销售负责人回填业绩归属地';
|
||||
end if;
|
||||
|
||||
if to_regclass('public.cnarea') is null then
|
||||
raise exception 'cnarea 表不存在,无法将业绩归属地解析为 area_code';
|
||||
end if;
|
||||
|
||||
select case
|
||||
when exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'sys_user'
|
||||
and column_name = 'user_id'
|
||||
) then 'user_id'
|
||||
when exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'sys_user'
|
||||
and column_name = 'id'
|
||||
) then 'id'
|
||||
else null
|
||||
end
|
||||
into v_user_pk;
|
||||
|
||||
if v_user_pk is null then
|
||||
raise exception 'sys_user 缺少 user_id/id 字段,无法关联 crm_opportunity.owner_user_id';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'sys_user'
|
||||
and column_name = 'display_name'
|
||||
) then
|
||||
v_name_queries := array_append(
|
||||
v_name_queries,
|
||||
format(
|
||||
'select u.%I::bigint as owner_user_id, nullif(btrim(u.display_name), '''') as owner_name from sys_user u where nullif(btrim(u.display_name), '''') is not null',
|
||||
v_user_pk
|
||||
)
|
||||
);
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'sys_user'
|
||||
and column_name = 'real_name'
|
||||
) then
|
||||
v_name_queries := array_append(
|
||||
v_name_queries,
|
||||
format(
|
||||
'select u.%I::bigint as owner_user_id, nullif(btrim(u.real_name), '''') as owner_name from sys_user u where nullif(btrim(u.real_name), '''') is not null',
|
||||
v_user_pk
|
||||
)
|
||||
);
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'sys_user'
|
||||
and column_name = 'username'
|
||||
) then
|
||||
v_name_queries := array_append(
|
||||
v_name_queries,
|
||||
format(
|
||||
'select u.%I::bigint as owner_user_id, nullif(btrim(u.username), '''') as owner_name from sys_user u where nullif(btrim(u.username), '''') is not null',
|
||||
v_user_pk
|
||||
)
|
||||
);
|
||||
end if;
|
||||
|
||||
if array_length(v_name_queries, 1) is null then
|
||||
raise exception 'sys_user 缺少 display_name/real_name/username 字段,无法匹配区域销售';
|
||||
end if;
|
||||
|
||||
drop table if exists tmp_opportunity_owner_name;
|
||||
create temp table tmp_opportunity_owner_name (
|
||||
owner_user_id bigint not null,
|
||||
owner_name text not null
|
||||
) on commit drop;
|
||||
|
||||
execute
|
||||
'insert into tmp_opportunity_owner_name (owner_user_id, owner_name) ' ||
|
||||
'select distinct owner_user_id, owner_name from (' ||
|
||||
array_to_string(v_name_queries, ' union all ') ||
|
||||
') t';
|
||||
|
||||
with owner_main_region(owner_name, main_region) as (
|
||||
values
|
||||
('苑磊', '山东'),
|
||||
('张贝贝', '河南'),
|
||||
('吕志威', '浙江'),
|
||||
('谈欣', '辽宁'),
|
||||
('丰世雄', '内蒙古'),
|
||||
('李继设', '广西'),
|
||||
('王昊玮', '山西'),
|
||||
('韩东升', '云南'),
|
||||
('冯文超', '湖北'),
|
||||
('徐凯', '四川'),
|
||||
('陈志华', '河北'),
|
||||
('向往', '江苏'),
|
||||
('王孟杰', '新疆'),
|
||||
('杨坤融', '新疆')
|
||||
)
|
||||
update crm_opportunity o
|
||||
set project_ownership_location = area.area_code
|
||||
from tmp_opportunity_owner_name owner_name
|
||||
join owner_main_region region
|
||||
on region.owner_name = owner_name.owner_name
|
||||
join cnarea area
|
||||
on area.level = 1
|
||||
and (
|
||||
area.short_name = region.main_region
|
||||
or area.name = region.main_region
|
||||
or area.area_code = region.main_region
|
||||
)
|
||||
where o.owner_user_id = owner_name.owner_user_id
|
||||
and o.project_ownership_location is distinct from area.area_code;
|
||||
|
||||
get diagnostics v_main_region_updated = row_count;
|
||||
|
||||
update crm_opportunity o
|
||||
set project_ownership_location = (
|
||||
select coalesce(
|
||||
case when matched_area.level = 1 then matched_area.area_code end,
|
||||
parent_area.area_code
|
||||
)
|
||||
from cnarea matched_area
|
||||
left join cnarea parent_area
|
||||
on parent_area.level = 1
|
||||
and parent_area.area_code = matched_area.parent_code
|
||||
where matched_area.level in (1, 2)
|
||||
and (
|
||||
matched_area.area_code = btrim(o.project_location)
|
||||
or matched_area.short_name = btrim(o.project_location)
|
||||
or matched_area.name = btrim(o.project_location)
|
||||
)
|
||||
order by
|
||||
case when matched_area.level = 1 then 0 else 1 end,
|
||||
matched_area.area_code asc,
|
||||
matched_area.id asc
|
||||
limit 1
|
||||
)
|
||||
from tmp_opportunity_owner_name owner_name
|
||||
where o.owner_user_id = owner_name.owner_user_id
|
||||
and owner_name.owner_name in ('党旭', '杨斯雷')
|
||||
and coalesce(nullif(btrim(o.project_location), ''), '') <> ''
|
||||
and (
|
||||
select coalesce(
|
||||
case when matched_area.level = 1 then matched_area.area_code end,
|
||||
parent_area.area_code
|
||||
)
|
||||
from cnarea matched_area
|
||||
left join cnarea parent_area
|
||||
on parent_area.level = 1
|
||||
and parent_area.area_code = matched_area.parent_code
|
||||
where matched_area.level in (1, 2)
|
||||
and (
|
||||
matched_area.area_code = btrim(o.project_location)
|
||||
or matched_area.short_name = btrim(o.project_location)
|
||||
or matched_area.name = btrim(o.project_location)
|
||||
)
|
||||
order by
|
||||
case when matched_area.level = 1 then 0 else 1 end,
|
||||
matched_area.area_code asc,
|
||||
matched_area.id asc
|
||||
limit 1
|
||||
) is not null
|
||||
and o.project_ownership_location is distinct from (
|
||||
select coalesce(
|
||||
case when matched_area.level = 1 then matched_area.area_code end,
|
||||
parent_area.area_code
|
||||
)
|
||||
from cnarea matched_area
|
||||
left join cnarea parent_area
|
||||
on parent_area.level = 1
|
||||
and parent_area.area_code = matched_area.parent_code
|
||||
where matched_area.level in (1, 2)
|
||||
and (
|
||||
matched_area.area_code = btrim(o.project_location)
|
||||
or matched_area.short_name = btrim(o.project_location)
|
||||
or matched_area.name = btrim(o.project_location)
|
||||
)
|
||||
order by
|
||||
case when matched_area.level = 1 then 0 else 1 end,
|
||||
matched_area.area_code asc,
|
||||
matched_area.id asc
|
||||
limit 1
|
||||
);
|
||||
|
||||
get diagnostics v_project_location_updated = row_count;
|
||||
|
||||
raise notice '业绩归属地回填完成:按主区域更新 % 条,按项目地更新 % 条',
|
||||
v_main_region_updated,
|
||||
v_project_location_updated;
|
||||
end $$;
|
||||
|
||||
commit;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
-- 2026-07-02 商机业绩归属地与数据授权区域范围升级脚本
|
||||
--
|
||||
-- 本次升级内容:
|
||||
-- 1. 为 crm_opportunity 增加 project_ownership_location 字段。
|
||||
-- 2. 将已有业绩归属地文本值尽量规范为 cnarea.level = 1 的 area_code。
|
||||
-- 3. 为 sys_user_data_scope_user 增加商机业绩归属地授权范围字段。
|
||||
-- 4. 旧授权默认保持“全部归属地”,不会收窄已有可见范围。
|
||||
|
||||
begin;
|
||||
|
||||
alter table if exists crm_opportunity
|
||||
add column if not exists project_ownership_location varchar(100);
|
||||
|
||||
comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code';
|
||||
|
||||
do $$
|
||||
begin
|
||||
if to_regclass('public.cnarea') is not null then
|
||||
update crm_opportunity o
|
||||
set project_ownership_location = c.area_code
|
||||
from cnarea c
|
||||
where c.level = 1
|
||||
and coalesce(nullif(btrim(o.project_ownership_location), ''), '') <> ''
|
||||
and (
|
||||
o.project_ownership_location = c.short_name
|
||||
or o.project_ownership_location = c.name
|
||||
)
|
||||
and o.project_ownership_location is distinct from c.area_code;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
alter table if exists sys_user_data_scope_user
|
||||
add column if not exists area_scope_type varchar(20) not null default 'ALL';
|
||||
|
||||
alter table if exists sys_user_data_scope_user
|
||||
add column if not exists area_codes varchar(100)[] not null default '{}'::varchar[];
|
||||
|
||||
update sys_user_data_scope_user
|
||||
set area_scope_type = 'ALL',
|
||||
area_codes = '{}'::varchar[]
|
||||
where area_scope_type is null
|
||||
or area_scope_type = '';
|
||||
|
||||
comment on column sys_user_data_scope_user.area_scope_type is '商机业绩归属地范围:ALL全部归属地/CUSTOM指定归属地';
|
||||
comment on column sys_user_data_scope_user.area_codes is '指定可见的商机业绩归属地编码列表,对应 cnarea.area_code';
|
||||
|
||||
commit;
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
- 首页经营分析生产升级脚本:`sql/upgrade_dashboard_analytics_prod_pg17.sql`
|
||||
- 经营分析卡片历史展示配置修正脚本:`sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
|
||||
- 商机实际签约金额字段升级脚本:`sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
||||
- 2026-07-02 商机业绩归属地与数据授权区域范围升级脚本:`sql/20260702_opportunity_ownership_and_scope_pg17.sql`
|
||||
- 日报提醒功能初始化脚本:`sql/init_report_reminder_pg17.sql`
|
||||
- 2026-06-24 CRM 增量升级脚本:`sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql`
|
||||
- 归属人转移权限初始化脚本:`sql/init_owner_transfer_permissions_pg17.sql`
|
||||
|
|
@ -81,6 +82,12 @@ psql -d your_database -f sql/upgrade_opportunity_actual_signed_amount_pg17.sql
|
|||
psql -d your_database -f sql/upgrade_opportunity_is_poc_pg17.sql
|
||||
```
|
||||
|
||||
如果老环境需要补“商机业绩归属地 + 数据授权区域范围”能力,请执行:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/20260702_opportunity_ownership_and_scope_pg17.sql
|
||||
```
|
||||
|
||||
如果希望直接按当前版本代码一次补齐“经营分析最新结构 + 商机快照字段 + 实际签约金额字段”,可直接执行合并脚本:
|
||||
|
||||
```bash
|
||||
|
|
@ -141,6 +148,9 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_card_display_config_pg1
|
|||
- `sql/upgrade_opportunity_is_poc_pg17.sql`
|
||||
老环境补齐“是否 POC 测试项目”字段时使用的正式增量脚本。
|
||||
|
||||
- `sql/20260702_opportunity_ownership_and_scope_pg17.sql`
|
||||
老环境补齐“商机业绩归属地 + 数据授权区域范围”能力时使用的正式增量脚本。
|
||||
|
||||
- `sql/init_report_reminder_pg17.sql`
|
||||
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ create table if not exists crm_opportunity (
|
|||
pre_sales_id bigint,
|
||||
pre_sales_name varchar(100),
|
||||
project_location varchar(100),
|
||||
project_ownership_location varchar(100),
|
||||
operator_name varchar(100),
|
||||
amount numeric(18, 2) not null default 0,
|
||||
actual_signed_amount numeric(18, 2),
|
||||
|
|
@ -590,6 +591,7 @@ ALTER TABLE IF EXISTS crm_opportunity
|
|||
ADD COLUMN IF NOT EXISTS pre_sales_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS project_location varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS project_ownership_location varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS competitor_name varchar(200),
|
||||
ADD COLUMN IF NOT EXISTS actual_signed_amount numeric(18, 2),
|
||||
|
|
@ -849,6 +851,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
|||
('crm_opportunity', 'pre_sales_id', '售前ID'),
|
||||
('crm_opportunity', 'pre_sales_name', '售前姓名'),
|
||||
('crm_opportunity', 'project_location', '项目所在地'),
|
||||
('crm_opportunity', 'project_ownership_location', '业绩归属地编码,对应 cnarea.area_code'),
|
||||
('crm_opportunity', 'operator_name', '运作方'),
|
||||
('crm_opportunity', 'amount', '商机金额'),
|
||||
('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue