Skip to content

Geographic & Territory Domain - Deep Dive Analysis

1. Domain Overview

1.1 领域职责描述

Geographic & Territory 领域负责管理制药企业销售组织的地理层级结构,这是整个 Speaker Platform 的核心基础设施之一。它的主要职责包括:

  1. 销售组织层级管理:维护 SalesForce -> Region -> District -> Territory 的四级地理层级结构
  2. Team 管理:管理跨地理区域的销售团队(Team),关联品牌(Brand)和项目类型(ProgramType)
  3. Planner 分配:将 Planner(计划者/协调人)分配到地理层级的各个节点,实现层级继承
  4. 用户权限绑定:通过 t_user_product 表将用户(Sales Rep / District Manager / Regional Manager)绑定到特定地理节点,决定其数据可见范围
  5. 预算关联:地理节点与预算分配(Budget Allocation)紧密关联,Region 和 District 层级可设置预算上限
  6. 会议/项目归属:每个 MeetingRequest(项目申请)都关联到特定的地理节点(Region/District/Territory)
  7. 节点重分配:支持 District 和 Territory 在层级间的重新分配(Reassign),并级联更新所有关联数据
  8. Speaker-District 映射:支持 Speaker 与 District 的多对多关联(合同区域限制)

1.2 涉及的后端模块和包

模块路径说明
geography 模块modules/v1/geography/核心地理管理(CRUD、重分配、Planner 分配)
dict 模块modules/v1/dict/地理数据字典查询(下拉框数据源)
config 模块modules/v1/config/CustomLabelConfiguration.java地理层级自定义标签配置
persistence/entitycommon/persistence/entity/10个核心实体类
persistence/mappercommon/persistence/mapper/MyBatis Mapper 接口与 XML

2. Data Model Analysis

2.1 Entity Overview Table

实体类表名主键核心字段文件位置
Regiont_regionregion_id (序列: s_region)regionName, regionCode, productId, plannerId, status, salesTeamId, salesForceIdentity/Region.java
Districtt_districtdistrict_id (序列: s_district)districtName, districtCode, regionId, productId, plannerId, statusentity/District.java
Territoryt_territoryterritory_id (序列: s_territory)territoryName, territoryCode, districtId, regionId, productId, plannerId, statusentity/Territory.java
SalesTeamt_sales_teamsales_team_id (序列: s_sales_team)salesTeamName, productId, status, speakerProgramReportDisabledentity/SalesTeam.java
Teamt_teamteam_id (序列: s_team)teamName, productId, status, salesTeamId, salesForceIdentity/Team.java
TeamBrandt_team_brand(teamId, brandId) 联合主键teamId, brandIdentity/TeamBrand.java
TeamProgramTypet_team_program_type(teamId, programTypeId) 联合主键teamId, programTypeIdentity/TeamProgramType.java
RegionManagert_region_manager(regionId, userId) 联合主键regionId, userIdentity/RegionManager.java
DistrictManagert_district_manager(districtId, userId) 联合主键districtId, userIdentity/DistrictManager.java
MappingSpeakerDistrictt_mapping_speaker_district(speakerId, districtId) 联合主键speakerId, districtIdentity/MappingSpeakerDistrict.java

关联实体(非 geography 模块但深度关联):

实体类表名地理相关字段
UserProductt_user_productsalesForceId, teamId, regionId, districtId, territoryId
MeetingRequestt_meeting_requestregionId, districtId, territoryId, teamId, salesForceId
BudgetCapLocalet_budget_cap_localeproductId, regionId, districtId, localeType, fiscalYear, initBgt, addBgt, brandId

数据库视图:

视图名定义用途
v_sales_forceSELECT sales_team_id AS sales_force_id, sales_team_name AS sales_force_name, product_id, status FROM t_sales_team将 SalesTeam 字段名映射为 SalesForce 字段名(兼容层)

2.2 Table Relationships (ER Diagram - ASCII)

t_product (productId)
    |
    |--- 1:N ---> t_sales_team (salesTeamId, productId)
    |                 |
    |                 |--- [v_sales_force 视图: salesTeamId -> salesForceId]
    |                 |
    |                 |--- 1:N ---> t_team (teamId, salesTeamId/salesForceId, productId)
    |                 |                |
    |                 |                |--- M:N ---> t_team_brand (teamId, brandId)
    |                 |                |--- M:N ---> t_team_program_type (teamId, programTypeId)
    |                 |
    |                 |--- 1:N ---> t_region (regionId, salesTeamId/salesForceId, productId)
    |                                  |
    |                                  |--- M:N ---> t_region_manager (regionId, userId)
    |                                  |
    |                                  |--- 1:N ---> t_district (districtId, regionId, productId)
    |                                                   |
    |                                                   |--- M:N ---> t_district_manager (districtId, userId)
    |                                                   |--- M:N ---> t_mapping_speaker_district (speakerId, districtId)
    |                                                   |
    |                                                   |--- 1:N ---> t_territory (territoryId, districtId, regionId, productId)
    |
    |--- 1:N ---> t_user_product (userProductId, userId, productId,
    |                              salesForceId, teamId, regionId, districtId, territoryId)
    |
    |--- 1:N ---> t_meeting_request (meetingRequestId,
    |                                 regionId, districtId, territoryId, teamId, salesForceId)
    |
    |--- 1:N ---> t_budget_cap_locale (budgetLocaleId, productId, regionId, districtId)

2.3 Data Model Issues

Issue DM-1: SalesTeam/SalesForce 双重命名混乱 (Critical)

问题描述:系统中存在严重的命名不一致问题。数据库表名为 t_sales_team,字段为 sales_team_idsales_team_name;但通过视图 v_sales_force 将其映射为 sales_force_idsales_force_name。在实体和代码中两套命名并存:

  • Region.java:43 - 同时有 salesTeamIdsalesForceId 两个字段
  • Team.java:37-40 - 同时有 salesTeamIdsalesForceId 两个字段
  • GeographyService.java:150 - region.setSalesTeamId(region.getSalesForceId()) 手动同步两个字段
  • TeamService.java:97 - team.setSalesForceId(team.getSalesTeamId()) 手动同步两个字段
  • DictionaryService.java:195-196 - salesforce.setSalesForceId(salesTeam.getSalesTeamId()) 手动映射
  • SalesForceRequest.java 使用 salesTeamIdsalesTeamName
  • SalesForceResponse.java 使用 salesTeamIdsalesTeamName
  • SalesforceDictionary.java 同时有 salesTeamId/salesTeamNamesalesForceId/salesForceName

影响:这是一个历史遗留的"SalesTeam 改名为 SalesForce"的不完整重构。开发者每次使用时都需要弄清楚用哪个字段名,容易出错。

Issue DM-2: Territory 冗余存储 regionId (Medium)

问题描述Territory 实体 (Territory.java:29) 同时存储了 districtIdregionId。由于 Territory -> District -> Region 是严格的父子关系,regionId 可以通过 District.regionId 间接获取,存储在 Territory 上是冗余的。

影响:重分配 Territory 时需要同步更新 regionId (ReassignTerritoryRequest 包含 regionIddistrictId),增加了数据不一致的风险。

Issue DM-3: Region/District/Territory 各自冗余存储 productId (Medium)

问题描述:三个层级实体都各自存储了 productId,但由于层级关系 (Region 属于 Product,District 属于 Region,Territory 属于 District),只需顶层存储即可。

影响:每创建一个下级节点都必须传入 productId,增加了请求体复杂度和数据不一致风险。

Issue DM-4: RegionManager/DistrictManager 表存在但未被 Geography 模块使用 (Low)

问题描述t_region_managert_district_manager 表有对应实体 (RegionManager.java, DistrictManager.java),但 GeographyServiceGeographyController 中完全没有引用这两个实体。用户与地理节点的关联实际通过 t_user_product 表的 region_id/district_id + 角色 (REGIONAL_MANAGER/DISTRICT_MANAGER) 来实现。

影响:这两个 Manager 关联表可能是废弃的旧设计,或者仅在其他模块中使用,造成理解困惑。

Issue DM-5: 软删除不统一 (Low)

问题描述

  • RegionDistrict 的删除会同时清除 t_budget_cap_locale 中的关联记录(硬删除:GeographyService.java:414, GeographyService.java:419
  • 而 Region/District/Territory 本身使用软删除(status = 0
  • SalesTeam 的删除也是软删除(GeographyService.java:107

Issue DM-6: District/Territory 实体编码风格不一致 (Low)

问题描述Region.java, RegionManager.java, DistrictManager.java, Team.java 使用 Lombok @Data 注解自动生成 getter/setter;而 District.java, Territory.java, SalesTeam.java 手写 getter/setter。这表明它们由不同时期的代码生成器生成,且后期没有统一更新。

3. Business Flow Analysis

3.1 Core Business Flows

Flow 1: Territory Hierarchy (SalesForce -> Region -> District -> Territory)

┌─────────────────────────────────────────────────────────────────┐
│                    Sales Organization Hierarchy                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│   Product (药品产品线)                                              │
│     │                                                              │
│     ├── SalesForce A (销售部队)          ← t_sales_team             │
│     │     │                                                        │
│     │     ├── Region 1 (大区)            ← t_region                │
│     │     │     ├── District 1 (区域)    ← t_district              │
│     │     │     │     ├── Territory 1    ← t_territory             │
│     │     │     │     └── Territory 2                               │
│     │     │     └── District 2                                     │
│     │     │           └── Territory 3                               │
│     │     └── Region 2                                             │
│     │           └── District 3                                     │
│     │                 └── Territory 4                               │
│     │                                                              │
│     └── SalesForce B                                               │
│           └── Region 3                                             │
│                 └── District 4                                     │
│                       └── Territory 5                               │
│                                                                   │
│   Team (跨地理团队)                     ← t_team                   │
│     ├── 关联 SalesForce (1:1)                                      │
│     ├── 关联 Brand[] (M:N)             ← t_team_brand              │
│     └── 关联 ProgramType[] (M:N)       ← t_team_program_type       │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Flow 2: Planner Assignment (层级继承机制)

AssignPlannerRequest 到达

    ├── territoryId != null?
    │     └── YES → 仅更新该 Territory 的 plannerId

    ├── districtId != null?
    │     └── YES → 更新该 District 的 plannerId
    │               + 更新该 District 下所有 Territory 的 plannerId

    ├── regionId != null?
    │     └── YES → 更新该 Region 的 plannerId
    │               + 更新该 Region 下所有 District 的 plannerId
    │               + 更新该 Region 下所有 Territory 的 plannerId

    ├── salesForceId != null?
    │     └── YES → 更新该 SalesForce 下所有 Region 的 plannerId
    │               + 获取 regionIds → 更新下属 District 和 Territory
    │               ⚠ BUG: 见 Issue BL-1

    └── 全部为 null?
          └── YES → 按 productId 更新所有 Region/District/Territory

Planner 查询逻辑 (getPlannerId, GeographyService.java:604-618):
    Territory.plannerId → District.plannerId → Region.plannerId
    (从最低层级向上查找第一个非 null 的 plannerId)

Flow 3: District Reassign (区域重分配)

ReassignDistrictRequest(productId, salesForceId, regionId, districtId)

    ├── 1. 查找 District 是否存在

    ├── 2. 检查 brandBudgetAllocationEnabled?
    │     └── YES → 查询当前 fiscal period
    │               → 检查该 District 是否有未反分配的预算
    │               → 有则抛出异常,要求先反分配

    ├── 3. 更新 District 的 regionId (和 salesForceId)

    ├── 4. 级联更新 t_user_product:
    │     └── 所有 districtId 匹配的 UserProduct → 更新 regionId, salesForceId

    └── 5. 级联更新 t_meeting_request:
          └── 所有 districtId 匹配的 MeetingRequest → 更新 regionId, salesForceId

Flow 4: Territory Reassign

ReassignTerritoryRequest(productId, regionId, districtId, territoryId)

    ├── 1. 查找 Territory 是否存在

    ├── 2. 更新 Territory 的 districtId 和 regionId

    ├── 3. 级联更新 t_user_product:
    │     └── 所有 territoryId 匹配的 UserProduct → 更新 regionId, districtId

    └── 4. 级联更新 t_meeting_request:
          └── 所有 territoryId 匹配的 MeetingRequest → 更新 regionId, districtId

Flow 5: User-Geography Binding (用户权限与地理绑定)

t_user_product 表:

    ├── SALES_PERSON (Sales Rep)
    │     └── 绑定到: salesForceId, teamId, regionId, districtId, territoryId
    │         → 只能看到自己 territory 下的项目

    ├── DISTRICT_MANAGER
    │     └── 绑定到: salesForceId, regionId, districtId
    │         → 可以看到自己 district 下所有 territory 的项目

    └── REGIONAL_MANAGER
          └── 绑定到: salesForceId, regionId
              → 可以看到自己 region 下所有 district/territory 的项目

权限检查通过 ProductUser 模型实现:
    - hasProductProgramsPermission()    → 产品级别权限
    - hasRegionProgramsPermission()     → Region 级别权限
    - hasDistrictProgramsPermission()   → District 级别权限
    - hasAllSalesForcesPermission()     → 所有 SalesForce 权限

Secondary Geography (二级地理区域):
    - ProductUser.secondaryRegionIds   → 用户可查看的附加 Region
    - ProductUser.secondaryDistrictIds → 用户可查看的附加 District

3.2 Validation Rules

规则位置描述
Region 名称唯一性GeographyService.java:364-371同一 productId 下,active 状态的 Region 名称不能重复
District 名称唯一性GeographyService.java:373-379同一 regionId 下,active 状态的 District 名称不能重复
Territory 名称唯一性GeographyService.java:382-388同一 districtId 下,active 状态的 Territory 名称不能重复
Region 删除前检查GeographyService.java:391-396Region 下有 District / ProductUser(REGIONAL_MANAGER) / MeetingRequest 时不可删除
District 删除前检查GeographyService.java:398-403District 下有 Territory / ProductUser(DISTRICT_MANAGER) / MeetingRequest 时不可删除
Territory 删除前检查GeographyService.java:405-408Territory 下有 ProductUser(SALES_PERSON) / MeetingRequest 时不可删除
District 重分配预算检查GeographyService.java:284-290当 brandBudgetAllocationEnabled 时,重分配前需检查该 District 的预算是否已反分配
SalesForce 创建SalesForceRequest.java:11-15salesTeamName 和 productId 为 @NotNull
Team 创建TeamRequest.java:11-14teamName 和 productId 为 @NotNull

3.3 Business Logic Issues

Issue BL-1: assignPlannerToSalesforce 逻辑反转 Bug (Critical)

文件: GeographyService.java:536-563

java
private void assignPlannerToSalesforce(AssignPlannerRequest request) {
    // ...更新 Region...
    final List<Region> regions = regionMapper.selectByExample(regionExample);
    if (CollectionUtil.isEmpty(regions)) {  // ← BUG: 条件反转!应为 isNotEmpty
      final List<Integer> regionIds = regions.stream()  // ← regions 为空时会得到空 list
          .map(regionItem -> regionItem.getRegionId())
          .collect(Collectors.toList());
      // ...后续代码永远不会正确执行...

问题isEmpty 应为 isNotEmpty。当前逻辑导致:当 SalesForce 下有 Region 时,子层级的 District/Territory 不会被更新 planner;当 SalesForce 下没有 Region 时,代码尝试在空列表上操作。

此外,第 560 行 territoryExample.createCriteria().andEqualTo("regionId", regionIds) 使用了 andEqualTo 而应该使用 andIn,即使条件修正也会导致 SQL 错误。

Issue BL-2: NPE in updateRegion/deleteRegion (Medium)

文件: GeographyService.java:155-159

java
public Region updateRegion(Integer regionId, RegionRequest request) {
    final Region region = regionMapper.selectByPrimaryKey(regionId);
    CustomLabelConfiguration customLabel = getProductConfiguration(region.getProductId()).getCustomLabel();
    if (region == null) {  // ← null 检查在 region.getProductId() 之后

region 可能为 null,但在 null 检查之前就调用了 region.getProductId(),会导致 NullPointerException。同样的问题存在于 deleteRegion (GeographyService.java:171-175) 和 deleteDistrict (GeographyService.java:222-225)。

Issue BL-3: Territory 删除不清理 BudgetCapLocale (Low)

文件: GeographyService.java:265-277

Region 和 District 删除时会清理 t_budget_cap_locale 关联记录,但 Territory 删除时没有。虽然 BudgetCapLocale 目前只有 regionIddistrictId 字段(没有 territoryId),但这个不对称行为值得注意。

Issue BL-4: updateTerritory 返回不一致的 HTTP 状态码 (Low)

文件: GeographyController.java:120-123

java
@PutMapping(value = "/territories/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)  // 204 No Content
public Territory updateTerritory(...) {  // 但是返回了 Territory 对象

@ResponseStatus(HttpStatus.NO_CONTENT) 与返回 Territory 对象矛盾。204 表示无内容,但实际返回了更新后的对象。updateRegionupdateDistrict 没有此问题。

Issue BL-5: 重分配时缺少 Territory 的 salesForceId 更新 (Medium)

文件: GeographyService.java:323-336

Territory 重分配时更新了 UserProductMeetingRequestregionIddistrictId,但没有更新 salesForceId。如果 Territory 跨 SalesForce 重分配(通过不同的 Region),关联的 UserProduct 和 MeetingRequest 的 salesForceId 会不一致。

Issue BL-6: 重分配 MeetingRequest 缺少事务控制 (Medium)

文件: GeographyService.java:279-301, GeographyService.java:323-336

reassignDistrictreassignTerritory 方法中更新 District/Territory 本身、更新 UserProduct、更新 MeetingRequest 三个步骤没有显式事务注解(@Transactional),如果中间步骤失败会导致数据不一致。

Issue BL-7: CustomLabel 在 Territory 操作中硬编码 (Low)

文件: GeographyService.java:247-248, GeographyService.java:258, GeographyService.java:268, GeographyService.java:273

Region 和 District 的错误消息使用 customLabel.getRegionLabel() / customLabel.getDistrictLabel() 支持自定义标签,但 Territory 相关的错误消息硬编码为 "Territory",没有使用 customLabel.getTerritoryLabel()

4. API Inventory

4.1 REST Endpoints Table

Geography Controller (v1/geographies)

MethodPath功能Query/RequestResponse文件:行
GET/v1/geographies列出地理层级(分页扁平化视图)GeographyQuery: productId, salesForceId, regionId, districtId, plannerId, status + 分页PageResult<GeographyResponse>GeographyController.java:47
GET/v1/geographies/regions获取 Region 列表productId (required)List<Region>GeographyController.java:53
GET/v1/geographies/regions/{id}获取单个 Regionpath: idRegionGeographyController.java:58
POST/v1/geographies/regions创建 RegionRegionRequest: productId, salesForceId, regionName, regionCode, statusRegionGeographyController.java:64
PUT/v1/geographies/regions/{id}更新 Regionpath: id + RegionRequestRegionGeographyController.java:70
DELETE/v1/geographies/regions/{id}删除 Region(软删除)path: id204 No ContentGeographyController.java:76
GET/v1/geographies/districts获取 District 列表productId (required)List<District>GeographyController.java:83
POST/v1/geographies/districts创建 DistrictDistrictRequest: productId, regionId, districtName, districtCode, statusDistrictGeographyController.java:89
PUT/v1/geographies/districts/{id}更新 Districtpath: id + DistrictRequestDistrictGeographyController.java:95
DELETE/v1/geographies/districts/{id}删除 District(软删除)path: id204 No ContentGeographyController.java:100
GET/v1/geographies/territories获取 Territory 列表productId (required)List<Territory>GeographyController.java:108
POST/v1/geographies/territories创建 TerritoryTerritoryRequest: productId, districtId, territoryName, territoryCode, statusTerritoryGeographyController.java:114
PUT/v1/geographies/territories/{id}更新 Territorypath: id + TerritoryRequestTerritory (但标注204)GeographyController.java:120
DELETE/v1/geographies/territories/{id}删除 Territory(软删除)path: id204 No ContentGeographyController.java:126
PUT/v1/geographies/districts/{id}/reassign重分配 Districtpath: id + ReassignDistrictRequest: productId, salesForceId, regionIdvoidGeographyController.java:133
PUT/v1/geographies/territories/{id}/reassign重分配 Territorypath: id + ReassignTerritoryRequest: productId, regionId, districtIdvoidGeographyController.java:140
PUT/v1/geographies/planners/assign分配 PlannerAssignPlannerRequest: productId, salesForceId, regionId, districtId, territoryId, plannerIdvoidGeographyController.java:148
GET/v1/geographies/program-geography查看项目地理分配ProgramGeographyQuery: productId, salesForceId, regionId, districtId, territoryId + 分页PageResult<ProgramGeographyResponse>GeographyController.java:153
PUT/v1/geographies/program-geography/reassign批量重分配项目地理ReassignProgramGeographyRequest: meetingRequestIds[], regionId, districtId, territoryIdvoidGeographyController.java:159

SalesForce Controller (v1/salesforces)

MethodPath功能Query/RequestResponse文件:行
GET/v1/salesforces列出 SalesForce(分页)SalesForceQuery: productId + 分页PageResult<SalesForceResponse>SalesForceController.java:35
POST/v1/salesforces创建 SalesForceSalesForceRequest: salesTeamName(@NotNull), productId(@NotNull), speakerProgramReportDisabledSalesForceResponseSalesForceController.java:41
PUT/v1/salesforces/{salesForceId}更新 SalesForcepath: salesForceId + SalesForceRequestSalesForceResponseSalesForceController.java:47
GET/v1/salesforces/{salesForceId}获取单个 SalesForcepath: salesForceIdSalesForceResponseSalesForceController.java:53
DELETE/v1/salesforces/{salesForceId}删除 SalesForce(软删除)path: salesForceId204 No ContentSalesForceController.java:58
PUT/v1/salesforces/{salesForceId}/status更新 SalesForce 状态path: salesForceId + SalesForceStatusRequest: statusSalesForceResponseSalesForceController.java:65

Team Controller (v1/teams)

MethodPath功能Query/RequestResponse文件:行
GET/v1/teams列出 Team(分页)TeamQuery: productId + 分页PageResult<TeamDTO>TeamController.java:35
POST/v1/teams创建 TeamTeamRequest: teamName(@NotNull), productId(@NotNull), salesTeamId, brandIds[], programTypeIds[]TeamDTOTeamController.java:41
PUT/v1/teams/{id}更新 Teampath: id + TeamRequestTeamDTOTeamController.java:47
DELETE/v1/teams/{id}删除 Team(主键删除)path: id204 No ContentTeamController.java:53
PUT/v1/teams/{id}/status更新 Team 状态path: id + query: statusTeamDTOTeamController.java:60

Dictionary Controller (v1/dictionary) - 地理相关

MethodPath功能ParametersResponse文件:行
GET/v1/dictionary/salesforcesSalesForce 下拉数据productId?List<SalesforceDictionary>DictionaryController.java:76
GET/v1/dictionary/regionsRegion 下拉数据RegionQuery: productId?, salesforceId?, status?List<RegionDictionary>DictionaryController.java:81
GET/v1/dictionary/districtsDistrict 下拉数据productId?, regionId?List<DistrictDictionary>DictionaryController.java:86
GET/v1/dictionary/territoriesTerritory 下拉数据productId?, regionId?, districtId?List<TerritoryDictionary>DictionaryController.java:92
GET/v1/dictionary/teamsTeam 下拉数据productId?List<TeamDictionary>DictionaryController.java:71
GET/v1/dictionary/district-managersDistrict Manager 列表productId?List<SalesRepDictionary>DictionaryController.java:109
GET/v1/dictionary/regional-managersRegional Manager 列表productId?List<SalesRepDictionary>DictionaryController.java:114

空壳 Controller (dict 模块内,无任何端点)

ControllerPath状态文件
RegionControllerv1/regions空壳,无端点dict/controller/RegionController.java
DistrictControllerv1空壳,无端点dict/controller/DistrictController.java
TerritoryControllerv1/territories空壳,无端点dict/controller/TerritoryController.java

4.2 API Design Issues

Issue API-1: 重复的空壳 Controller (Medium)

问题: dict 模块下的 RegionControllerDistrictControllerTerritoryController 都是空壳,与 geography 模块的 GeographyController 功能重叠。DistrictController 的 path 还是不完整的 v1

Issue API-2: 端点组织不清晰 (Medium)

问题: Region/District/Territory 的 CRUD 放在 v1/geographies 下(GeographyController),而 SalesForce 的 CRUD 放在 v1/salesforces 下(SalesForceController),Team 的 CRUD 放在 v1/teams 下(TeamController),字典查询放在 v1/dictionary 下。同一领域的实体分散在 4 个不同的 Controller 中。

Issue API-3: GeographyQuery 缺少 productId 作为必填参数 (Medium)

问题: GeographyQuery (GeographyQuery.java) 没有声明 productId 字段,但 GeographyMapper.xml:81 的 SQL 中 WHERE product_id = #{productId} 是必须的。productId 实际来自 PageQuery 基类,但没有 @NotNull 约束,如果前端忘记传 productId 会返回空结果或 SQL 错误。

Issue API-4: Region/District/Territory 查询接口返回原始实体 (Low)

问题: getRegions, getDistricts, getTerritories 直接返回数据库实体 (Region, District, Territory),暴露了内部数据结构(如 salesTeamId/salesForceId 双字段)。应使用 DTO 封装。

Issue API-5: 安全性 - 缺少权限检查 (Critical)

问题: Geography 相关的所有 Controller 方法没有任何权限注解(如 @PreAuthorize),仅通过 JWT 验证用户身份。任何登录用户理论上都可以创建/修改/删除地理节点和重分配 Planner,这应该是仅限管理员的操作。

5. Frontend Analysis

5.1 Pages & Components

Salesview (pharmagin-salesview)

Geography 管理页面 (/salesview/geography)

文件类型功能
pages/Geography/index.js路由容器注入 reducer 和 saga,路由到 GeographyList
pages/Geography/List/index.js主列表页显示扁平化地理层级表格,包含 8 列(SalesForce/Region/District/Territory + Code + Planner + Status)
pages/Geography/List/Filter.js筛选组件左侧筛选栏,支持 SalesForce/Region/District/Planner/Status 级联筛选
pages/Geography/Form/RegionAddForm.js表单新增 Region
pages/Geography/Form/RegionEditForm.js表单编辑 Region
pages/Geography/Form/DistrictAddForm.js表单新增 District
pages/Geography/Form/DistrictEditForm.js表单编辑 District
pages/Geography/Form/TerritoryAddForm.js表单新增 Territory
pages/Geography/Form/TerritoryEditForm.js表单编辑 Territory
pages/Geography/Form/ReassignDistrictForm.js表单重分配 District
pages/Geography/Form/ReassignTerritoryForm.js表单重分配 Territory
pages/Geography/Form/AssignPlannerForm.js表单分配 Planner 到地理节点
pages/Geography/Form/AssignPlannerByProgramType.js表单按 ProgramType 分配 Planner
pages/Geography/actions.jsRedux Actions12 个 routines
pages/Geography/reducer.jsRedux Reducergeographies + filters 状态
pages/Geography/saga.jsRedux Saga11 个 saga workers
pages/Geography/selectors.jsSelectorsgeographies / filters / loading / status

Reassign Program Geography 页面 (/salesview/reassign-program-geography)

文件类型功能
pages/ReassignProgramGeography/index.js路由容器独立页面,批量重分配项目地理归属
pages/ReassignProgramGeography/List/index.js列表项目列表,可选中批量操作
pages/ReassignProgramGeography/List/Filter.js筛选按地理层级筛选项目
pages/ReassignProgramGeography/Form/ReassignProgramForm.js表单选择目标地理节点进行重分配

Budget Allocation 页面 (深度关联地理)

文件功能
pages/BudgetAllocation/List/RegionBudgetAllocation.jsRegion 级预算分配
pages/BudgetAllocation/List/RegionBudgetTable.jsRegion 预算表格
pages/BudgetAllocation/List/RegionBudgetFilter.jsRegion 预算筛选
pages/BudgetAllocation/List/DistrictBudgetTable.jsDistrict 预算表格
pages/BudgetAllocation/List/DistrictBudgetFilter.jsDistrict 预算筛选
pages/BudgetAllocation/List/BudgetCapByLocaleTable.js按地理位置的预算上限
pages/BudgetAllocation/Modal/LocaleBudgetForm.js地理位置预算设置表单

Reports 页面 (地理维度报表)

文件功能
pages/Reports/ProgramActivity/components/GeographySpendReportList/按地理维度的花费报表
pages/Reports/ProgramActivity/components/ProgramActivityByGeographyAndSpend.js地理+花费交叉报表
pages/Reports/AttendeeAnalytics/RegistrationByGeography/按地理维度的注册分析

其他引用地理数据的页面:

  • pages/Programs/Form/ - 创建项目时选择 Region/District/Territory
  • pages/Users/Form/ - 创建/编辑用户时分配地理区域
  • pages/Calendar/ - 日历筛选器包含地理维度

Plannerview (pharmagin-plannerview)

文件类型功能
components/SalesForce/index.js管理页面Admin 页面中 SalesForce 管理(列表+CRUD Modal)
components/SalesForce/actions.jsActions3 个 action: fetchSalesForces, saveSalesForce, changeSalesForceStatus
components/SalesForce/sagas.jsSagas3 个 saga: GET/POST-PUT/PUT status
components/SalesForce/reducer.jsReducersalesForces 列表状态
components/SalesForce/selectors.jsSelectorssalesForces / pagination / requestStatus
components/Team/index.js管理页面Admin 页面中 Team 管理(列表+CRUD Modal,关联 Brand 和 ProgramType)
components/Team/actions.jsActionsTeam CRUD actions
components/Team/sagas.jsSagasTeam API 调用
components/Team/reducer.jsReducerteam 列表状态
components/Team/selectors.jsSelectorsteam/salesForce/brand 选择器
containers/AdminPage/constants.js常量定义 "Setup Sales Force" 和 "Setup Team" 菜单项

Speakerview (pharmagin-speakerview)

文件类型功能
components/ContractDistrict/index.js表单组件Speaker 合同的 District 设置(选择 Region + 多选 District)
components/ContractProfile/容器Speaker 合同详情,包含 District 关联管理

5.2 Redux State Structure

Salesview Geography State

javascript
// Store key: 'geography'
{
  geographies: {
    list: [
      {
        salesForceId, salesForceName,
        regionId, regionName, regionCode, regionStatus,
        districtId, districtName, districtCode, districtStatus,
        territoryId, territoryName, territoryCode, territoryStatus,
        status,           // 计算后的综合状态
        plannerName,      // 继承计算后的 planner 名称
      }
    ],
    pagination: { current, pageSize, total },
  },
  filters: {
    salesForceId?,
    regionId?,
    districtId?,
    plannerId?,
    status?,
  },
}

// Store key: 'app' (全局字典数据,地理相关)
{
  salesForces: [],     // List<SalesforceDictionary>
  regions: [],         // List<RegionDictionary>
  districts: [],       // List<DistrictDictionary>
  territories: [],     // List<TerritoryDictionary>
  planners: [],        // List<PlannerDictionary>
  programTypes: [],    // List<ProgramTypeDictionary>
}

Plannerview SalesForce State

javascript
// Store key: 'salesforce' (注入 AdminPage)
{
  listRequestStatus: 'idle' | 'request' | 'success' | 'failure',
  saveRequestStatus: 'idle' | 'request' | 'success' | 'failure',
  statusRequestStatus: 'idle' | 'request' | 'success' | 'failure',
  salesForces: [],
  pagination: { current, pageSize, total },
}

5.3 Frontend Issues

Issue FE-1: Geography 页面 pageSize 默认 500 行 (Medium)

文件: salesview/src/pages/Geography/List/index.js:45

javascript
pagination: {
  current: 1,
  pageSize: 500,  // ← 默认加载 500 行
  size: 'small',
},

对于大型客户,可能有数千个 Territory,500 行的默认分页过大,影响性能。

Issue FE-2: 级联筛选依赖前端内存过滤 (Medium)

文件: salesview/src/pages/Geography/List/Filter.js:80-86

javascript
const currentRegions = regions.filter(
  region => getFieldValue('salesForceId') == region.salesForceId,
);
const currentDistricts = districts.filter(
  district => getFieldValue('regionId') == district.regionId,
);

Region/District 的级联筛选在前端通过内存过滤实现,需要一次性加载所有 Region 和 District 数据。如果数据量大会造成内存压力。

Issue FE-3: Territory 列标签硬编码 (Low)

文件: salesview/src/pages/Geography/List/index.js:111-119

javascript
{
  title: `Territory`,          // ← 硬编码
  dataIndex: 'territoryName',
},
{
  title: `Territory Code`,     // ← 硬编码
  dataIndex: 'territoryCode',
},

Region 和 District 使用 customLabel.regionLabel / customLabel.districtLabel,但 Territory 和 "Add Territory" 按钮 (List/index.js:303) 以及 "Reassign Territory" 按钮 (List/index.js:309) 都是硬编码的。

Issue FE-4: Plannerview SalesForce 组件使用旧版生命周期 (Low)

文件: plannerview/legacy/src/components/SalesForce/index.js:41

使用已废弃的 componentWillReceiveProps 生命周期方法,应迁移到 getDerivedStateFromProps 或使用 hooks。

Issue FE-5: 错误处理不一致 (Low)

文件: salesview/src/pages/Geography/saga.js:24-26

javascript
} catch (err) {
    console.log(err);  // ← 只打印到控制台,用户看不到错误提示
}

requestGeographies 的错误处理只 console.log,不像其他 saga(如 editRegion)会 put(failure(message))

6. Problem Summary

6.1 Critical Issues (必须在重写时修复)

#问题位置影响
C-1assignPlannerToSalesforce 逻辑反转 Bug + andEqualTo 应为 andInGeographyService.java:547-561按 SalesForce 分配 Planner 功能完全失效
C-2SalesTeam/SalesForce 双重命名混乱,需手动同步两个字段全域 (Region, Team, DictionaryService, GeographyService 等)维护成本高,容易引入数据不一致
C-3NPE - null 检查在使用对象之后GeographyService.java:157-158, 172-174, 223-225当 Region/District 不存在时会 NPE 而非返回友好错误
C-4缺少 API 权限控制所有 Geography/SalesForce/Team Controller任何登录用户可修改地理结构
C-5缺少事务控制GeographyService reassign 方法重分配操作部分成功部分失败导致数据不一致

6.2 Design Defects (应该改进)

#问题位置建议
D-1Territory 冗余存储 regionIdTerritory.java:29重写时通过 District 关系推导
D-2Region/District/Territory 各自冗余存储 productId所有三个实体通过层级关系推导
D-3Territory 重分配不更新 salesForceIdGeographyService.java:323-336应级联更新所有相关字段
D-4RegionManager/DistrictManager 表可能废弃entity/RegionManager.java, entity/DistrictManager.java确认是否废弃,如是则清理
D-5API 返回原始实体而非 DTOGeographyController 的 Region/District/Territory 接口使用 Response DTO 封装
D-6空壳 Controller 冗余dict/controller/RegionController.java删除或合并
D-7Territory 的 CustomLabel 硬编码GeographyService.java:247-273, salesview List/index.js:111-119, 303, 309统一使用 customLabel.territoryLabel
D-8地理字典数据全量加载 + 前端内存过滤salesview Filter.js:80-86改为后端级联查询

6.3 Technical Debt (改善项)

#问题位置说明
T-1实体编码风格不一致(Lombok vs 手写)District.java, Territory.java, SalesTeam.java统一使用 Lombok
T-2Geography 页面默认 pageSize=500salesview Geography/List/index.js:45应使用合理默认值如 20-50
T-3使用废弃的 React 生命周期方法plannerview SalesForce/index.js:41迁移到 hooks 或新生命周期
T-4Saga 错误处理不一致salesview Geography/saga.js:24-26统一使用 failure action
T-5v_sales_force 视图仅做字段重命名changelog-2.0.3.xml:1388-1394重写时统一命名,删除视图
T-6Dictionary 接口无缓存DictionaryService地理数据变更不频繁,可添加缓存
T-7updateTerritory 返回 Territory 但标注 204GeographyController.java:120-121统一返回策略

7. Rewrite Recommendations

7.1 命名统一

首要任务:解决 SalesTeam/SalesForce 的命名混乱。建议统一为 SalesForce

  • 数据库表重命名 t_sales_team -> t_sales_force
  • 所有字段统一为 sales_force_id, sales_force_name
  • 删除 v_sales_force 视图
  • 实体类重命名 SalesTeam -> SalesForce

7.2 数据模型重构

Product
  └── SalesForce (was SalesTeam)
        ├── Region (移除 productId 冗余, salesTeamId -> salesForceId)
        │     └── District (移除 productId 冗余)
        │           └── Territory (移除 productId/regionId 冗余)
        └── Team
              ├── TeamBrand
              └── TeamProgramType
  • 移除 Territory.regionId 和各级的 productId 冗余字段
  • 评估 RegionManager/DistrictManager 表是否废弃,与 UserProduct 基于角色的方案统一
  • 评估 MappingSpeakerDistrict 是否仍在使用

7.3 Planner 分配重构

  • 修复 assignPlannerToSalesforce Bug
  • 考虑将 Planner 分配从每个 Region/District/Territory 实体的 plannerId 字段提取为独立的关联表
  • 或保留当前继承模型但明确文档化继承规则

7.4 API 重构

建议将地理相关 API 统一到一个模块下:

/api/v2/geography/
  ├── /salesforces          (CRUD)
  ├── /regions              (CRUD)
  ├── /districts            (CRUD)
  ├── /territories          (CRUD)
  ├── /teams                (CRUD)
  ├── /planners/assign      (Planner 分配)
  ├── /reassign/districts   (District 重分配)
  ├── /reassign/territories (Territory 重分配)
  └── /reassign/programs    (项目地理重分配)
  • 添加权限控制注解
  • 添加 @Transactional 事务注解
  • 使用 Response DTO,不暴露内部实体
  • 级联查询接口(按 SalesForce 获取树形结构)
  • 删除空壳 Controller

7.5 前端重构

  • 使用树形控件展示地理层级,替代当前的扁平表格
  • 级联选择器改为后端驱动(按需加载子级数据)
  • 统一使用 customLabel 配置,消除硬编码标签
  • 合理分页(默认 20-50 行)
  • 统一错误处理和用户反馈
  • Plannerview 中的 SalesForce/Team 管理考虑迁移到 Salesview 的 Geography 模块下统一管理