Geographic & Territory Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Geographic & Territory 领域负责管理制药企业销售组织的地理层级结构,这是整个 Speaker Platform 的核心基础设施之一。它的主要职责包括:
- 销售组织层级管理:维护 SalesForce -> Region -> District -> Territory 的四级地理层级结构
- Team 管理:管理跨地理区域的销售团队(Team),关联品牌(Brand)和项目类型(ProgramType)
- Planner 分配:将 Planner(计划者/协调人)分配到地理层级的各个节点,实现层级继承
- 用户权限绑定:通过
t_user_product表将用户(Sales Rep / District Manager / Regional Manager)绑定到特定地理节点,决定其数据可见范围 - 预算关联:地理节点与预算分配(Budget Allocation)紧密关联,Region 和 District 层级可设置预算上限
- 会议/项目归属:每个 MeetingRequest(项目申请)都关联到特定的地理节点(Region/District/Territory)
- 节点重分配:支持 District 和 Territory 在层级间的重新分配(Reassign),并级联更新所有关联数据
- Speaker-District 映射:支持 Speaker 与 District 的多对多关联(合同区域限制)
1.2 涉及的后端模块和包
| 模块 | 路径 | 说明 |
|---|---|---|
| geography 模块 | modules/v1/geography/ | 核心地理管理(CRUD、重分配、Planner 分配) |
| dict 模块 | modules/v1/dict/ | 地理数据字典查询(下拉框数据源) |
| config 模块 | modules/v1/config/CustomLabelConfiguration.java | 地理层级自定义标签配置 |
| persistence/entity | common/persistence/entity/ | 10个核心实体类 |
| persistence/mapper | common/persistence/mapper/ | MyBatis Mapper 接口与 XML |
2. Data Model Analysis
2.1 Entity Overview Table
| 实体类 | 表名 | 主键 | 核心字段 | 文件位置 |
|---|---|---|---|---|
Region | t_region | region_id (序列: s_region) | regionName, regionCode, productId, plannerId, status, salesTeamId, salesForceId | entity/Region.java |
District | t_district | district_id (序列: s_district) | districtName, districtCode, regionId, productId, plannerId, status | entity/District.java |
Territory | t_territory | territory_id (序列: s_territory) | territoryName, territoryCode, districtId, regionId, productId, plannerId, status | entity/Territory.java |
SalesTeam | t_sales_team | sales_team_id (序列: s_sales_team) | salesTeamName, productId, status, speakerProgramReportDisabled | entity/SalesTeam.java |
Team | t_team | team_id (序列: s_team) | teamName, productId, status, salesTeamId, salesForceId | entity/Team.java |
TeamBrand | t_team_brand | (teamId, brandId) 联合主键 | teamId, brandId | entity/TeamBrand.java |
TeamProgramType | t_team_program_type | (teamId, programTypeId) 联合主键 | teamId, programTypeId | entity/TeamProgramType.java |
RegionManager | t_region_manager | (regionId, userId) 联合主键 | regionId, userId | entity/RegionManager.java |
DistrictManager | t_district_manager | (districtId, userId) 联合主键 | districtId, userId | entity/DistrictManager.java |
MappingSpeakerDistrict | t_mapping_speaker_district | (speakerId, districtId) 联合主键 | speakerId, districtId | entity/MappingSpeakerDistrict.java |
关联实体(非 geography 模块但深度关联):
| 实体类 | 表名 | 地理相关字段 |
|---|---|---|
UserProduct | t_user_product | salesForceId, teamId, regionId, districtId, territoryId |
MeetingRequest | t_meeting_request | regionId, districtId, territoryId, teamId, salesForceId |
BudgetCapLocale | t_budget_cap_locale | productId, regionId, districtId, localeType, fiscalYear, initBgt, addBgt, brandId |
数据库视图:
| 视图名 | 定义 | 用途 |
|---|---|---|
v_sales_force | SELECT 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_id 和 sales_team_name;但通过视图 v_sales_force 将其映射为 sales_force_id 和 sales_force_name。在实体和代码中两套命名并存:
Region.java:43- 同时有salesTeamId和salesForceId两个字段Team.java:37-40- 同时有salesTeamId和salesForceId两个字段GeographyService.java:150-region.setSalesTeamId(region.getSalesForceId())手动同步两个字段TeamService.java:97-team.setSalesForceId(team.getSalesTeamId())手动同步两个字段DictionaryService.java:195-196-salesforce.setSalesForceId(salesTeam.getSalesTeamId())手动映射SalesForceRequest.java使用salesTeamId和salesTeamNameSalesForceResponse.java使用salesTeamId和salesTeamNameSalesforceDictionary.java同时有salesTeamId/salesTeamName和salesForceId/salesForceName
影响:这是一个历史遗留的"SalesTeam 改名为 SalesForce"的不完整重构。开发者每次使用时都需要弄清楚用哪个字段名,容易出错。
Issue DM-2: Territory 冗余存储 regionId (Medium)
问题描述:Territory 实体 (Territory.java:29) 同时存储了 districtId 和 regionId。由于 Territory -> District -> Region 是严格的父子关系,regionId 可以通过 District.regionId 间接获取,存储在 Territory 上是冗余的。
影响:重分配 Territory 时需要同步更新 regionId (ReassignTerritoryRequest 包含 regionId 和 districtId),增加了数据不一致的风险。
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_manager 和 t_district_manager 表有对应实体 (RegionManager.java, DistrictManager.java),但 GeographyService 和 GeographyController 中完全没有引用这两个实体。用户与地理节点的关联实际通过 t_user_product 表的 region_id/district_id + 角色 (REGIONAL_MANAGER/DISTRICT_MANAGER) 来实现。
影响:这两个 Manager 关联表可能是废弃的旧设计,或者仅在其他模块中使用,造成理解困惑。
Issue DM-5: 软删除不统一 (Low)
问题描述:
Region和District的删除会同时清除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, salesForceIdFlow 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, districtIdFlow 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 → 用户可查看的附加 District3.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-396 | Region 下有 District / ProductUser(REGIONAL_MANAGER) / MeetingRequest 时不可删除 |
| District 删除前检查 | GeographyService.java:398-403 | District 下有 Territory / ProductUser(DISTRICT_MANAGER) / MeetingRequest 时不可删除 |
| Territory 删除前检查 | GeographyService.java:405-408 | Territory 下有 ProductUser(SALES_PERSON) / MeetingRequest 时不可删除 |
| District 重分配预算检查 | GeographyService.java:284-290 | 当 brandBudgetAllocationEnabled 时,重分配前需检查该 District 的预算是否已反分配 |
| SalesForce 创建 | SalesForceRequest.java:11-15 | salesTeamName 和 productId 为 @NotNull |
| Team 创建 | TeamRequest.java:11-14 | teamName 和 productId 为 @NotNull |
3.3 Business Logic Issues
Issue BL-1: assignPlannerToSalesforce 逻辑反转 Bug (Critical)
文件: GeographyService.java:536-563
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
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 目前只有 regionId 和 districtId 字段(没有 territoryId),但这个不对称行为值得注意。
Issue BL-4: updateTerritory 返回不一致的 HTTP 状态码 (Low)
文件: GeographyController.java:120-123
@PutMapping(value = "/territories/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT) // 204 No Content
public Territory updateTerritory(...) { // 但是返回了 Territory 对象@ResponseStatus(HttpStatus.NO_CONTENT) 与返回 Territory 对象矛盾。204 表示无内容,但实际返回了更新后的对象。updateRegion 和 updateDistrict 没有此问题。
Issue BL-5: 重分配时缺少 Territory 的 salesForceId 更新 (Medium)
文件: GeographyService.java:323-336
Territory 重分配时更新了 UserProduct 和 MeetingRequest 的 regionId 和 districtId,但没有更新 salesForceId。如果 Territory 跨 SalesForce 重分配(通过不同的 Region),关联的 UserProduct 和 MeetingRequest 的 salesForceId 会不一致。
Issue BL-6: 重分配 MeetingRequest 缺少事务控制 (Medium)
文件: GeographyService.java:279-301, GeographyService.java:323-336
reassignDistrict 和 reassignTerritory 方法中更新 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)
| Method | Path | 功能 | Query/Request | Response | 文件:行 |
|---|---|---|---|---|---|
| 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} | 获取单个 Region | path: id | Region | GeographyController.java:58 |
| POST | /v1/geographies/regions | 创建 Region | RegionRequest: productId, salesForceId, regionName, regionCode, status | Region | GeographyController.java:64 |
| PUT | /v1/geographies/regions/{id} | 更新 Region | path: id + RegionRequest | Region | GeographyController.java:70 |
| DELETE | /v1/geographies/regions/{id} | 删除 Region(软删除) | path: id | 204 No Content | GeographyController.java:76 |
| GET | /v1/geographies/districts | 获取 District 列表 | productId (required) | List<District> | GeographyController.java:83 |
| POST | /v1/geographies/districts | 创建 District | DistrictRequest: productId, regionId, districtName, districtCode, status | District | GeographyController.java:89 |
| PUT | /v1/geographies/districts/{id} | 更新 District | path: id + DistrictRequest | District | GeographyController.java:95 |
| DELETE | /v1/geographies/districts/{id} | 删除 District(软删除) | path: id | 204 No Content | GeographyController.java:100 |
| GET | /v1/geographies/territories | 获取 Territory 列表 | productId (required) | List<Territory> | GeographyController.java:108 |
| POST | /v1/geographies/territories | 创建 Territory | TerritoryRequest: productId, districtId, territoryName, territoryCode, status | Territory | GeographyController.java:114 |
| PUT | /v1/geographies/territories/{id} | 更新 Territory | path: id + TerritoryRequest | Territory (但标注204) | GeographyController.java:120 |
| DELETE | /v1/geographies/territories/{id} | 删除 Territory(软删除) | path: id | 204 No Content | GeographyController.java:126 |
| PUT | /v1/geographies/districts/{id}/reassign | 重分配 District | path: id + ReassignDistrictRequest: productId, salesForceId, regionId | void | GeographyController.java:133 |
| PUT | /v1/geographies/territories/{id}/reassign | 重分配 Territory | path: id + ReassignTerritoryRequest: productId, regionId, districtId | void | GeographyController.java:140 |
| PUT | /v1/geographies/planners/assign | 分配 Planner | AssignPlannerRequest: productId, salesForceId, regionId, districtId, territoryId, plannerId | void | GeographyController.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, territoryId | void | GeographyController.java:159 |
SalesForce Controller (v1/salesforces)
| Method | Path | 功能 | Query/Request | Response | 文件:行 |
|---|---|---|---|---|---|
| GET | /v1/salesforces | 列出 SalesForce(分页) | SalesForceQuery: productId + 分页 | PageResult<SalesForceResponse> | SalesForceController.java:35 |
| POST | /v1/salesforces | 创建 SalesForce | SalesForceRequest: salesTeamName(@NotNull), productId(@NotNull), speakerProgramReportDisabled | SalesForceResponse | SalesForceController.java:41 |
| PUT | /v1/salesforces/{salesForceId} | 更新 SalesForce | path: salesForceId + SalesForceRequest | SalesForceResponse | SalesForceController.java:47 |
| GET | /v1/salesforces/{salesForceId} | 获取单个 SalesForce | path: salesForceId | SalesForceResponse | SalesForceController.java:53 |
| DELETE | /v1/salesforces/{salesForceId} | 删除 SalesForce(软删除) | path: salesForceId | 204 No Content | SalesForceController.java:58 |
| PUT | /v1/salesforces/{salesForceId}/status | 更新 SalesForce 状态 | path: salesForceId + SalesForceStatusRequest: status | SalesForceResponse | SalesForceController.java:65 |
Team Controller (v1/teams)
| Method | Path | 功能 | Query/Request | Response | 文件:行 |
|---|---|---|---|---|---|
| GET | /v1/teams | 列出 Team(分页) | TeamQuery: productId + 分页 | PageResult<TeamDTO> | TeamController.java:35 |
| POST | /v1/teams | 创建 Team | TeamRequest: teamName(@NotNull), productId(@NotNull), salesTeamId, brandIds[], programTypeIds[] | TeamDTO | TeamController.java:41 |
| PUT | /v1/teams/{id} | 更新 Team | path: id + TeamRequest | TeamDTO | TeamController.java:47 |
| DELETE | /v1/teams/{id} | 删除 Team(主键删除) | path: id | 204 No Content | TeamController.java:53 |
| PUT | /v1/teams/{id}/status | 更新 Team 状态 | path: id + query: status | TeamDTO | TeamController.java:60 |
Dictionary Controller (v1/dictionary) - 地理相关
| Method | Path | 功能 | Parameters | Response | 文件:行 |
|---|---|---|---|---|---|
| GET | /v1/dictionary/salesforces | SalesForce 下拉数据 | productId? | List<SalesforceDictionary> | DictionaryController.java:76 |
| GET | /v1/dictionary/regions | Region 下拉数据 | RegionQuery: productId?, salesforceId?, status? | List<RegionDictionary> | DictionaryController.java:81 |
| GET | /v1/dictionary/districts | District 下拉数据 | productId?, regionId? | List<DistrictDictionary> | DictionaryController.java:86 |
| GET | /v1/dictionary/territories | Territory 下拉数据 | productId?, regionId?, districtId? | List<TerritoryDictionary> | DictionaryController.java:92 |
| GET | /v1/dictionary/teams | Team 下拉数据 | productId? | List<TeamDictionary> | DictionaryController.java:71 |
| GET | /v1/dictionary/district-managers | District Manager 列表 | productId? | List<SalesRepDictionary> | DictionaryController.java:109 |
| GET | /v1/dictionary/regional-managers | Regional Manager 列表 | productId? | List<SalesRepDictionary> | DictionaryController.java:114 |
空壳 Controller (dict 模块内,无任何端点)
| Controller | Path | 状态 | 文件 |
|---|---|---|---|
RegionController | v1/regions | 空壳,无端点 | dict/controller/RegionController.java |
DistrictController | v1 | 空壳,无端点 | dict/controller/DistrictController.java |
TerritoryController | v1/territories | 空壳,无端点 | dict/controller/TerritoryController.java |
4.2 API Design Issues
Issue API-1: 重复的空壳 Controller (Medium)
问题: dict 模块下的 RegionController、DistrictController、TerritoryController 都是空壳,与 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.js | Redux Actions | 12 个 routines |
pages/Geography/reducer.js | Redux Reducer | geographies + filters 状态 |
pages/Geography/saga.js | Redux Saga | 11 个 saga workers |
pages/Geography/selectors.js | Selectors | geographies / 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.js | Region 级预算分配 |
pages/BudgetAllocation/List/RegionBudgetTable.js | Region 预算表格 |
pages/BudgetAllocation/List/RegionBudgetFilter.js | Region 预算筛选 |
pages/BudgetAllocation/List/DistrictBudgetTable.js | District 预算表格 |
pages/BudgetAllocation/List/DistrictBudgetFilter.js | District 预算筛选 |
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/Territorypages/Users/Form/- 创建/编辑用户时分配地理区域pages/Calendar/- 日历筛选器包含地理维度
Plannerview (pharmagin-plannerview)
| 文件 | 类型 | 功能 |
|---|---|---|
components/SalesForce/index.js | 管理页面 | Admin 页面中 SalesForce 管理(列表+CRUD Modal) |
components/SalesForce/actions.js | Actions | 3 个 action: fetchSalesForces, saveSalesForce, changeSalesForceStatus |
components/SalesForce/sagas.js | Sagas | 3 个 saga: GET/POST-PUT/PUT status |
components/SalesForce/reducer.js | Reducer | salesForces 列表状态 |
components/SalesForce/selectors.js | Selectors | salesForces / pagination / requestStatus |
components/Team/index.js | 管理页面 | Admin 页面中 Team 管理(列表+CRUD Modal,关联 Brand 和 ProgramType) |
components/Team/actions.js | Actions | Team CRUD actions |
components/Team/sagas.js | Sagas | Team API 调用 |
components/Team/reducer.js | Reducer | team 列表状态 |
components/Team/selectors.js | Selectors | team/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
// 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
// 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
pagination: {
current: 1,
pageSize: 500, // ← 默认加载 500 行
size: 'small',
},对于大型客户,可能有数千个 Territory,500 行的默认分页过大,影响性能。
Issue FE-2: 级联筛选依赖前端内存过滤 (Medium)
文件: salesview/src/pages/Geography/List/Filter.js:80-86
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
{
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
} catch (err) {
console.log(err); // ← 只打印到控制台,用户看不到错误提示
}requestGeographies 的错误处理只 console.log,不像其他 saga(如 editRegion)会 put(failure(message))。
6. Problem Summary
6.1 Critical Issues (必须在重写时修复)
| # | 问题 | 位置 | 影响 |
|---|---|---|---|
| C-1 | assignPlannerToSalesforce 逻辑反转 Bug + andEqualTo 应为 andIn | GeographyService.java:547-561 | 按 SalesForce 分配 Planner 功能完全失效 |
| C-2 | SalesTeam/SalesForce 双重命名混乱,需手动同步两个字段 | 全域 (Region, Team, DictionaryService, GeographyService 等) | 维护成本高,容易引入数据不一致 |
| C-3 | NPE - 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-1 | Territory 冗余存储 regionId | Territory.java:29 | 重写时通过 District 关系推导 |
| D-2 | Region/District/Territory 各自冗余存储 productId | 所有三个实体 | 通过层级关系推导 |
| D-3 | Territory 重分配不更新 salesForceId | GeographyService.java:323-336 | 应级联更新所有相关字段 |
| D-4 | RegionManager/DistrictManager 表可能废弃 | entity/RegionManager.java, entity/DistrictManager.java | 确认是否废弃,如是则清理 |
| D-5 | API 返回原始实体而非 DTO | GeographyController 的 Region/District/Territory 接口 | 使用 Response DTO 封装 |
| D-6 | 空壳 Controller 冗余 | dict/controller/RegionController.java 等 | 删除或合并 |
| D-7 | Territory 的 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-2 | Geography 页面默认 pageSize=500 | salesview Geography/List/index.js:45 | 应使用合理默认值如 20-50 |
| T-3 | 使用废弃的 React 生命周期方法 | plannerview SalesForce/index.js:41 | 迁移到 hooks 或新生命周期 |
| T-4 | Saga 错误处理不一致 | salesview Geography/saga.js:24-26 | 统一使用 failure action |
| T-5 | v_sales_force 视图仅做字段重命名 | changelog-2.0.3.xml:1388-1394 | 重写时统一命名,删除视图 |
| T-6 | Dictionary 接口无缓存 | DictionaryService | 地理数据变更不频繁,可添加缓存 |
| T-7 | updateTerritory 返回 Territory 但标注 204 | GeographyController.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 分配重构
- 修复
assignPlannerToSalesforceBug - 考虑将 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 模块下统一管理