权限控制到商机的业绩归属地,优化

main
kangwenjing 2026-07-03 09:02:48 +08:00
parent cd8feef377
commit 8b2838c086
39 changed files with 1377 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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), ''), '') &lt;&gt; ''
and coalesce(nullif(btrim(short_name), ''), '') &lt;&gt; ''
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`。 |

View File

@ -28,6 +28,7 @@
| -------------------- | ------- | -------------------------------------- |
| `opportunityName` | string | 商机名称 |
| `projectLocation` | string | 项目地 |
| `projectOwnershipLocation` | string | 业绩归属地编码,对应 `cnarea.area_code` |
| `operatorName` | string | 运作方 |
| `amount` | number | 商机金额,历史数据回写不再限制必须大于 0 |
| `actualSignedAmount` | number | 实际签约金额,兼容字段名 `actual_signed_amount` |

View File

@ -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 | 预计结单日期 |

View File

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

View File

@ -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 ? (

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import http from "./http";
import {
BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
PermissionNode, 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。

View File

@ -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', '实际签约金额'),