From 8b2838c086298890b450d1de36514e6dbdb78fb5 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 3 Jul 2026 09:02:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6=E5=88=B0?= =?UTF-8?q?=E5=95=86=E6=9C=BA=E7=9A=84=E4=B8=9A=E7=BB=A9=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E5=9C=B0=EF=BC=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../17-44dcb808751c460b843f8cfe42e3b199.zip | 1 + .../common/OpportunitySchemaInitializer.java | 2 + .../UserDataScopeSchemaInitializer.java | 10 + .../UserDataScopeAdminController.java | 7 + .../datascope/UserDataScopeAssignmentDTO.java | 27 ++ .../dto/datascope/UserDataScopeGrantDTO.java | 27 ++ .../datascope/UserDataScopeOwnerRuleDTO.java | 53 ++++ .../opportunity/CreateOpportunityRequest.java | 11 + .../dto/opportunity/OpportunityItemDTO.java | 18 ++ .../dto/opportunity/OpportunityMetaDTO.java | 11 + .../UpdateOpportunityIntegrationRequest.java | 11 + .../tools/OpportunitySearchToolProvider.java | 2 +- .../unis/crm/mapper/OpportunityMapper.java | 8 + .../crm/service/CrmDataVisibilityService.java | 9 +- .../service/UserDataScopeAdminService.java | 186 +++++++++++- .../impl/CrmDataVisibilityServiceImpl.java | 98 ++++++- .../service/impl/OpportunityServiceImpl.java | 50 +++- .../resources/mapper/llm/LlmMcpMapper.xml | 21 +- .../mapper/opportunity/OpportunityMapper.xml | 71 ++++- .../UserDataScopeAdminServiceTest.java | 45 +++ .../CrmDataVisibilityServiceImplTest.java | 18 +- .../impl/ExpansionServiceImplTest.java | 2 +- .../impl/OpportunityServiceImplTest.java | 8 +- .../crm/service/impl/WorkServiceImplTest.java | 6 +- docs/OMS接口整理.md | 1 + docs/opportunity-integration-api.md | 1 + docs/数据结构.md | 1 + frontend/src/lib/auth.ts | 4 + frontend/src/pages/Opportunities.tsx | 37 +++ frontend/src/pages/Work.tsx | 24 ++ frontend1/dist/index.html | 2 +- frontend1/src/api/index.ts | 7 +- .../pages/system/user-data-scope/index.less | 63 +++++ .../pages/system/user-data-scope/index.tsx | 267 +++++++++++++++++- frontend1/src/types/index.ts | 23 ++ ...ll_opportunity_ownership_location_pg17.sql | 234 +++++++++++++++ ...2_opportunity_ownership_and_scope_pg17.sql | 47 +++ sql/README.md | 10 + sql/init_full_pg17.sql | 3 + 39 files changed, 1377 insertions(+), 49 deletions(-) create mode 100644 backend/build/test-uploads/work-report-attachments/17-44dcb808751c460b843f8cfe42e3b199.zip create mode 100644 backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeOwnerRuleDTO.java create mode 100644 sql/20260702_backfill_opportunity_ownership_location_pg17.sql create mode 100644 sql/20260702_opportunity_ownership_and_scope_pg17.sql diff --git a/backend/build/test-uploads/work-report-attachments/17-44dcb808751c460b843f8cfe42e3b199.zip b/backend/build/test-uploads/work-report-attachments/17-44dcb808751c460b843f8cfe42e3b199.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-44dcb808751c460b843f8cfe42e3b199.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java index 3d9ee88e..8fd6a622 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -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'"); diff --git a/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java index ba1a235e..66bd25fe 100644 --- a/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/UserDataScopeSchemaInitializer.java @@ -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); diff --git a/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java index fd36a001..c3fc0f55 100644 --- a/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java +++ b/backend/src/main/java/com/unis/crm/controller/UserDataScopeAdminController.java @@ -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> listProjectOwnershipLocations( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(userDataScopeAdminService.listProjectOwnershipLocations(tenantId)); + } + @GetMapping("/assignment") public ApiResponse getAssignment( @RequestParam(value = "tenantId", required = false) Long tenantId, diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java index 5f896411..d0543636 100644 --- a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeAssignmentDTO.java @@ -11,6 +11,9 @@ public class UserDataScopeAssignmentDTO { private String resourceType; private List resourceTypes = new ArrayList<>(); private List ownerUserIds = new ArrayList<>(); + private List ownerRules = new ArrayList<>(); + private String areaScopeType; + private List areaCodes = new ArrayList<>(); private OffsetDateTime expireAt; private String remark; private Boolean enabled; @@ -55,6 +58,30 @@ public class UserDataScopeAssignmentDTO { this.ownerUserIds = ownerUserIds; } + public List getOwnerRules() { + return ownerRules; + } + + public void setOwnerRules(List ownerRules) { + this.ownerRules = ownerRules; + } + + public String getAreaScopeType() { + return areaScopeType; + } + + public void setAreaScopeType(String areaScopeType) { + this.areaScopeType = areaScopeType; + } + + public List getAreaCodes() { + return areaCodes; + } + + public void setAreaCodes(List areaCodes) { + this.areaCodes = areaCodes; + } + public OffsetDateTime getExpireAt() { return expireAt; } diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java index 9e00cee7..7d527325 100644 --- a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeGrantDTO.java @@ -11,6 +11,9 @@ public class UserDataScopeGrantDTO { private Long ownerUserId; private String ownerName; private String resourceType; + private String areaScopeType; + private java.util.List areaCodes = new java.util.ArrayList<>(); + private java.util.List 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 getAreaCodes() { + return areaCodes; + } + + public void setAreaCodes(java.util.List areaCodes) { + this.areaCodes = areaCodes; + } + + public java.util.List getAreaNames() { + return areaNames; + } + + public void setAreaNames(java.util.List areaNames) { + this.areaNames = areaNames; + } + public Boolean getEnabled() { return enabled; } diff --git a/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeOwnerRuleDTO.java b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeOwnerRuleDTO.java new file mode 100644 index 00000000..a93f49ac --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/datascope/UserDataScopeOwnerRuleDTO.java @@ -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 areaCodes = new ArrayList<>(); + private List 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 getAreaCodes() { + return areaCodes; + } + + public void setAreaCodes(List areaCodes) { + this.areaCodes = areaCodes; + } + + public List getAreaNames() { + return areaNames; + } + + public void setAreaNames(List areaNames) { + this.areaNames = areaNames; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java index cd2703aa..64c7ce30 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java index 5ae5b9fc..5c81c21d 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java index 5953758a..ab33d9f8 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java @@ -7,6 +7,7 @@ public class OpportunityMetaDTO { private List stageOptions; private List operatorOptions; private List projectLocationOptions; + private List projectOwnershipLocationOptions; private List opportunityTypeOptions; private List confidenceOptions; private List sysIsOptions; @@ -18,12 +19,14 @@ public class OpportunityMetaDTO { List stageOptions, List operatorOptions, List projectLocationOptions, + List projectOwnershipLocationOptions, List opportunityTypeOptions, List confidenceOptions, List 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 getProjectOwnershipLocationOptions() { + return projectOwnershipLocationOptions; + } + + public void setProjectOwnershipLocationOptions(List projectOwnershipLocationOptions) { + this.projectOwnershipLocationOptions = projectOwnershipLocationOptions; + } + public List getOpportunityTypeOptions() { return opportunityTypeOptions; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java index 335eb421..40a568af 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java b/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java index 8c941c0c..b7b8b0d2 100644 --- a/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java +++ b/backend/src/main/java/com/unis/crm/llm/tools/OpportunitySearchToolProvider.java @@ -37,7 +37,7 @@ public class OpportunitySearchToolProvider extends PermissionedMcpToolProvider { @Override protected Map buildInputSchema() { Map 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。")); diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index b385ccc1..3e72cd9f 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -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 selectProvinceAreaOptions(); + List 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 visibleOwnerUserIds, + @Param("visibleOwnerAreaRules") List visibleOwnerAreaRules, @Param("preSalesUserId") Long preSalesUserId, @Param("preSalesUserNames") List preSalesUserNames); @@ -44,6 +50,7 @@ public interface OpportunityMapper { @Param("opportunityId") Long opportunityId, @Param("allDataAccess") boolean allDataAccess, @Param("visibleOwnerUserIds") List visibleOwnerUserIds, + @Param("visibleOwnerAreaRules") List visibleOwnerAreaRules, @Param("preSalesUserId") Long preSalesUserId, @Param("preSalesUserNames") List preSalesUserNames); @@ -52,6 +59,7 @@ public interface OpportunityMapper { @Param("opportunityIds") List opportunityIds, @Param("allDataAccess") boolean allDataAccess, @Param("visibleOwnerUserIds") List visibleOwnerUserIds, + @Param("visibleOwnerAreaRules") List visibleOwnerAreaRules, @Param("preSalesUserId") Long preSalesUserId, @Param("preSalesUserNames") List preSalesUserNames); diff --git a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java index 000bbfe2..3a235371 100644 --- a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java +++ b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java @@ -14,6 +14,13 @@ public interface CrmDataVisibilityService { record DataVisibility( boolean allDataAccess, - List visibleOwnerUserIds) { + List visibleOwnerUserIds, + List visibleOwnerAreaRules) { + } + + record OwnerAreaRule( + Long ownerUserId, + boolean allAreas, + List areaCodes) { } } diff --git a/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java index 397465d8..39da9e49 100644 --- a/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java +++ b/backend/src/main/java/com/unis/crm/service/UserDataScopeAdminService.java @@ -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 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 resourceTypes = normalizeResourceTypes(payload.getResourceTypes(), payload.getResourceType()); requireUserInTenant(tenantId, viewerUserId, "查看人不存在或不属于当前租户"); - List ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId); - validateOwnersInTenant(tenantId, ownerUserIds); + List 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 normalizeOwnerRules(UserDataScopeAssignmentDTO payload, Long viewerUserId) { + List ownerRules = payload.getOwnerRules(); + if (ownerRules != null && !ownerRules.isEmpty()) { + Map 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 ownerUserIds = normalizeOwnerUserIds(payload.getOwnerUserIds(), viewerUserId); + String areaScopeType = normalizeAreaScopeType(payload.getAreaScopeType()); + List areaCodes = normalizeAreaCodes(payload.getAreaCodes()); + if (AREA_SCOPE_CUSTOM.equals(areaScopeType) && areaCodes.isEmpty()) { + throw new BusinessException("指定业绩归属地时请选择至少一个归属地"); + } + List 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 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 normalizeOwnerUserIds(List 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 normalizeAreaCodes(List areaCodes) { + if (areaCodes == null || areaCodes.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String areaCode : areaCodes) { + String value = trimToNull(areaCode); + if (value != null) { + normalized.add(value); + } + } + return List.copyOf(normalized); + } + + private List 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 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; diff --git a/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java index 31f38fa7..efda7097 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImpl.java @@ -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 visibleOwnerUserIds = new LinkedHashSet<>(); + List 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 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 queryExplicitVisibleOwnerUserIds(Long currentUserId, Long tenantId, String resourceType) { + private List 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 target, List source) { if (source == null || source.isEmpty()) { return; @@ -78,6 +99,65 @@ public class CrmDataVisibilityServiceImpl implements CrmDataVisibilityService { } } + private void addAllAreaRules(List target, List 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 mergeOwnerAreaRules(List source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + Map> 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 merged = new ArrayList<>(); + for (Map.Entry> entry : grouped.entrySet()) { + boolean allAreas = entry.getValue().stream().anyMatch(OwnerAreaRule::allAreas); + if (allAreas) { + merged.add(new OwnerAreaRule(entry.getKey(), true, List.of())); + continue; + } + Set 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 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 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; diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index f2df9f50..64b532a7 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -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 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 normalizeVisibleOwnerAreaRules(List ownerAreaRules) { + if (ownerAreaRules == null || ownerAreaRules.isEmpty()) { + return List.of(); + } + List 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 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 visibleOwnerUserIds, + List visibleOwnerAreaRules, Long preSalesUserId, List preSalesUserNames) { } diff --git a/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml b/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml index 613b5798..21d3f21d 100644 --- a/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml +++ b/backend/src/main/resources/mapper/llm/LlmMcpMapper.xml @@ -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 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}, '%') ) @@ -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') and o.owner_user_id = #{ownerUserId} @@ -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}, '%') ) @@ -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 and o.owner_user_id = #{ownerUserId} @@ -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}, '%') ) diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index e94537be..b3a52e7a 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -7,10 +7,23 @@ - - or o.owner_user_id in - - #{ownerUserId} + + or + + + + o.owner_user_id = #{ownerRule.ownerUserId} + + + ( + o.owner_user_id = #{ownerRule.ownerUserId} + and o.project_ownership_location in + + #{areaCode} + + ) + + @@ -22,7 +35,7 @@ #{preSalesUserName} - + 1 = 0 @@ -49,6 +62,37 @@ order by area_code asc, id asc + + + + handleChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.opportunityName))} /> @@ -3575,6 +3611,7 @@ export default function Opportunities() { ) : null}
+ } /> {hasSelectedChannelExpansion ? ( diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index 85d1c0f2..bd046e79 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -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([]); const [quickOpportunityOperatorOptions, setQuickOpportunityOperatorOptions] = useState([]); const [quickOpportunityProjectLocationOptions, setQuickOpportunityProjectLocationOptions] = useState([]); + const [quickOpportunityProjectOwnershipLocationOptions, setQuickOpportunityProjectOwnershipLocationOptions] = useState([]); const [quickOpportunityTypeOptions, setQuickOpportunityTypeOptions] = useState([]); const [quickOpportunityStageOptions, setQuickOpportunityStageOptions] = useState([]); const [quickOpportunityConfidenceOptions, setQuickOpportunityConfidenceOptions] = useState([]); @@ -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 ?

{quickOpportunityFieldErrors.projectLocation}

: null} +