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<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,
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<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;
     }
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<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;
     }
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<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;
+    }
+}
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<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;
     }
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<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。"));
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<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);
 
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<Long> visibleOwnerUserIds) {
+            List<Long> visibleOwnerUserIds,
+            List<OwnerAreaRule> visibleOwnerAreaRules) {
+    }
+
+    record OwnerAreaRule(
+            Long ownerUserId,
+            boolean allAreas,
+            List<String> 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<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;
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<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;
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<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) {
     }
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
         <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>
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 @@
     <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>
diff --git a/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java
index 5e92d366..bd6e4c84 100644
--- a/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java
+++ b/backend/src/test/java/com/unis/crm/service/UserDataScopeAdminServiceTest.java
@@ -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),
diff --git a/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java
index f7eef460..e1fc73d4 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/CrmDataVisibilityServiceImplTest.java
@@ -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());
     }
 }
diff --git a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
index ca077364..d62c284a 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/ExpansionServiceImplTest.java
@@ -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());
diff --git a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
index 6acd38a3..a240be9b 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java
@@ -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());
 
diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
index 05002b74..9c6fbcd2 100644
--- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
+++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java
@@ -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));
 
diff --git a/docs/OMS接口整理.md b/docs/OMS接口整理.md
index d4e47555..41c41858 100644
--- a/docs/OMS接口整理.md
+++ b/docs/OMS接口整理.md
@@ -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`。 |
diff --git a/docs/opportunity-integration-api.md b/docs/opportunity-integration-api.md
index b10309fd..bd13de82 100644
--- a/docs/opportunity-integration-api.md
+++ b/docs/opportunity-integration-api.md
@@ -28,6 +28,7 @@
 | -------------------- | ------- | -------------------------------------- |
 | `opportunityName`    | string  | 商机名称                                   |
 | `projectLocation`    | string  | 项目地                                    |
+| `projectOwnershipLocation` | string | 业绩归属地编码，对应 `cnarea.area_code` |
 | `operatorName`       | string  | 运作方                                    |
 | `amount`             | number  | 商机金额，历史数据回写不再限制必须大于 0                  |
 | `actualSignedAmount` | number  | 实际签约金额，兼容字段名 `actual_signed_amount`        |
diff --git a/docs/数据结构.md b/docs/数据结构.md
index 5d402000..a23476ef 100644
--- a/docs/数据结构.md
+++ b/docs/数据结构.md
@@ -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 | 预计结单日期 |
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
index 11c6ff02..4d4f20e6 100644
--- a/frontend/src/lib/auth.ts
+++ b/frontend/src/lib/auth.ts
@@ -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;
diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx
index 185c52b2..4aee82d1 100644
--- a/frontend/src/pages/Opportunities.tsx
+++ b/frontend/src/pages/Opportunities.tsx
@@ -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 ? (
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<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))} />
diff --git a/frontend1/dist/index.html b/frontend1/dist/index.html
index d27c23a3..6e7cc1a3 100644
--- a/frontend1/dist/index.html
+++ b/frontend1/dist/index.html
@@ -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>
diff --git a/frontend1/src/api/index.ts b/frontend1/src/api/index.ts
index 8d8dfc42..9976d4df 100644
--- a/frontend1/src/api/index.ts
+++ b/frontend1/src/api/index.ts
@@ -1,7 +1,7 @@
 import http from "./http";
 import {
   BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
-  PermissionNode, 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;
diff --git a/frontend1/src/pages/system/user-data-scope/index.less b/frontend1/src/pages/system/user-data-scope/index.less
index 828345bd..df16f1b6 100644
--- a/frontend1/src/pages/system/user-data-scope/index.less
+++ b/frontend1/src/pages/system/user-data-scope/index.less
@@ -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;
+  }
 }
diff --git a/frontend1/src/pages/system/user-data-scope/index.tsx b/frontend1/src/pages/system/user-data-scope/index.tsx
index 502151ed..1a07a7e9 100644
--- a/frontend1/src/pages/system/user-data-scope/index.tsx
+++ b/frontend1/src/pages/system/user-data-scope/index.tsx
@@ -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">
diff --git a/frontend1/src/types/index.ts b/frontend1/src/types/index.ts
index 0a226d47..0afbba79 100644
--- a/frontend1/src/types/index.ts
+++ b/frontend1/src/types/index.ts
@@ -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;
diff --git a/sql/20260702_backfill_opportunity_ownership_location_pg17.sql b/sql/20260702_backfill_opportunity_ownership_location_pg17.sql
new file mode 100644
index 00000000..d729f2ed
--- /dev/null
+++ b/sql/20260702_backfill_opportunity_ownership_location_pg17.sql
@@ -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;
diff --git a/sql/20260702_opportunity_ownership_and_scope_pg17.sql b/sql/20260702_opportunity_ownership_and_scope_pg17.sql
new file mode 100644
index 00000000..6fbabbd9
--- /dev/null
+++ b/sql/20260702_opportunity_ownership_and_scope_pg17.sql
@@ -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;
diff --git a/sql/README.md b/sql/README.md
index 8262d5b1..b7ff1268 100644
--- a/sql/README.md
+++ b/sql/README.md
@@ -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`
   老环境补齐“日报提醒”功能时使用的正式增量脚本，已包含表结构与权限。
 
diff --git a/sql/init_full_pg17.sql b/sql/init_full_pg17.sql
index c2edd31d..33b310b9 100644
--- a/sql/init_full_pg17.sql
+++ b/sql/init_full_pg17.sql
@@ -128,6 +128,7 @@ create table if not exists crm_opportunity (
   pre_sales_id bigint,
   pre_sales_name varchar(100),
   project_location varchar(100),
+  project_ownership_location varchar(100),
   operator_name varchar(100),
   amount numeric(18, 2) not null default 0,
   actual_signed_amount numeric(18, 2),
@@ -590,6 +591,7 @@ ALTER TABLE IF EXISTS crm_opportunity
   ADD COLUMN IF NOT EXISTS pre_sales_id bigint,
   ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100),
   ADD COLUMN IF NOT EXISTS project_location varchar(100),
+  ADD COLUMN IF NOT EXISTS project_ownership_location varchar(100),
   ADD COLUMN IF NOT EXISTS operator_name varchar(100),
   ADD COLUMN IF NOT EXISTS competitor_name varchar(200),
   ADD COLUMN IF NOT EXISTS actual_signed_amount numeric(18, 2),
@@ -849,6 +851,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
     ('crm_opportunity', 'pre_sales_id', '售前ID'),
     ('crm_opportunity', 'pre_sales_name', '售前姓名'),
     ('crm_opportunity', 'project_location', '项目所在地'),
+    ('crm_opportunity', 'project_ownership_location', '业绩归属地编码，对应 cnarea.area_code'),
     ('crm_opportunity', 'operator_name', '运作方'),
     ('crm_opportunity', 'amount', '商机金额'),
     ('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
