Reporting & Analytics Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
报告与分析领域是 Speaker Platform 中面向数据消费的核心领域,负责为三类用户(Planner、Sales Rep、Speaker)提供多维度的数据可视化、统计分析和数据导出能力。该领域覆盖以下核心职责:
- Program Activity 报告体系 - 项目活动统计、预算花费分析、地理区域分析、销售代表活动分析
- Attendee Analytics 报告体系 - 参会者追踪、注册摘要、目标/非目标分析、按专业/地理/项目统计
- At a Glance 仪表板 - 整体概览(项目数量、出席者总数、Top Speaker、整体花费)
- Ad-hoc Report Builder - 用户自定义报告字段选择、拖拽排序、运行与导出
- Custom Reports - 基于 R/Python 脚本的自定义报告(服务端执行脚本生成 Excel)
- Compliance Audit - 合规审计报告
- Data Extract - 原始数据提取服务(15 种数据表直接导出)
- Aggregate Spend Report - 聚合花费报告及 Porzio/Verona 合规报告
- Survey Reports - 调查问卷统计分析
- Excel/CSV 导出引擎 - 基于注解驱动的通用导出框架
1.2 涉及的后端模块和包
| 模块/包 | 路径 | 职责 |
|---|---|---|
| report | modules/v1/report/ | 主报告模块(3 个 Service, 2 个 Controller, 20+ Request/Response) |
| adhoc | modules/v1/adhoc/ | Ad-hoc 报告构建器(1 个 Service, 1 个 Controller) |
| compliance | modules/v1/compliance/ | 合规报告及聚合花费(1 个 Service, 1 个 Controller) |
| ExcelService | common/service/ExcelService.java | 通用 Excel/CSV 导出引擎 |
| ExcelColumn | common/annotation/ExcelColumn.java | 字段导出元数据注解 |
2. Data Model Analysis
2.1 Entity Overview Table
| Entity | Table | 文件路径 | 字段 | 说明 |
|---|---|---|---|---|
AdhocReport | t_adhoc_report | common/persistence/entity/AdhocReport.java | id, meetingId, reportName, createdAt, updatedAt | Ad-hoc 报告定义 |
AdhocReportDetail | t_adhoc_report_detail | common/persistence/entity/AdhocReportDetail.java | id, adhocReportId, fieldLabel, sequence | Ad-hoc 报告字段选择(哪些列、什么顺序) |
ReportField | t_report_field | common/persistence/entity/ReportField.java | reportFieldId, reportFieldLabel | 报告字段字典(自定义注册字段) |
AggregateSpendReport | t_aggregate_spend_report | common/persistence/entity/AggregateSpendReport.java | id, productId, fieldId, createdAt, createdBy | 聚合花费报告配置 |
AggregateSpendReportField | t_aggregate_spend_report_field | common/persistence/entity/AggregateSpendReportField.java | id, fieldName, fieldLabel | 聚合花费报告字段定义 |
2.2 Table Relationships (ER Diagram - ASCII)
+-------------------+ 1:N +------------------------+
| t_adhoc_report |<--------------->| t_adhoc_report_detail |
|-------------------| |------------------------|
| id (PK) | | id (PK) |
| meeting_id (FK) | | adhoc_report_id (FK) |
| report_name | | field_label |
| created_at | | sequence |
| updated_at | +------------------------+
+-------------------+ |
| | field_label maps to
| meeting_id v
v +-------------------+
+-------------------+ | t_report_field |
| t_meeting | |-------------------|
| (program context) | | report_field_id |
+-------------------+ | report_field_label|
+-------------------+
^
| also maps to
+---------------------------+
| AdhocReportFieldStorage |
| (hardcoded field mapping) |
| 34 system fields |
+---------------------------+
+----------------------------+ 1:N +----------------------------------+
| t_aggregate_spend_report |<--------------->| (implicit: field mapping via |
|----------------------------| | t_aggregate_spend_report_field) |
| id (PK) | |----------------------------------|
| product_id (FK) | | id (PK) |
| field_id (FK) | | field_name |
| created_at | | field_label |
| created_by | +----------------------------------+
+----------------------------+2.3 Data Model Issues
AdhocReportDetail 双 @Id 注解错误 (
AdhocReportDetail.java:10-14)id和adhocReportId都标记了@Id,这在 JPA 中意味着复合主键,但实际上adhocReportId是外键,不应该是@Id。这可能导致 MyBatis tk.mapper 行为异常。
报告领域无专属数据模型,严重依赖其他领域表
- 报告数据全部通过
ProductReportMapper的复杂 SQL JOIN 查询从 meeting、attendee、budget、speaker、user 等核心表中汇总。报告领域本身只有 5 张表,但ProductReportMapper.xml中的 SQL 可能包含数十张表的 JOIN。
- 报告数据全部通过
AdhocReportFieldStorage 硬编码字段映射 (
adhoc/model/AdhocReportFieldStorage.java:11-50)- 34 个系统字段的 label-to-fieldName 映射硬编码在 Java 静态 Map 中,无法通过配置或数据库动态扩展。新增参会者字段必须改代码。
ReportField 表与 AdhocReportFieldStorage 职责重叠
t_report_field存储动态的自定义注册字段,而AdhocReportFieldStorage存储系统预定义字段。两者合并后才是 Ad-hoc 报告可用的完整字段列表。这种拆分增加了理解和维护成本。
3. Business Flow Analysis
3.1 Core Business Flows (ASCII Flow Diagrams)
3.1.1 标准报告生成流程(以 Program Summary Download 为例)
+------------------+ GET /v1/products/{id}/reports/ +--------------------+
| Salesview | program-activity/program-summary | ProductReport |
| (Frontend) |-------------------------------------------->| Controller |
+------------------+ query params: year, periodFrom, +--------------------+
periodTo, productId |
v
+--------------------+
| ProductReport |
| Service |
+--------------------+
|
+------------------------------------------------+
|
v
+-------------------------+
| 1. Check user |
| permissions |
| (Product/Region/ |
| District scope) |
+-------------------------+
|
v
+-------------------------+
| 2. ProductReportMapper |
| .getProgramSummary |
| Responses(query) |
| [Complex SQL JOIN] |
+-------------------------+
|
v
+-------------------------+
| 3. Optional: Calculate |
| AttendeeMetrics |
| (prescribers, |
| targets) from |
| targets.xlsx file |
+-------------------------+
|
v
+-------------------------+
| 4. Optional: Add |
| BudgetCategory |
| dynamic columns |
+-------------------------+
|
v
+-------------------------+
| 5. ExcelService.export |
| (annotation-driven |
| XLSX generation) |
+-------------------------+
|
v
+-------------------------+
| 6. HTTP Response |
| Content-Disposition |
| attachment |
+-------------------------+3.1.2 Ad-hoc Report Builder 流程
+------------------+ +--------------------+
| Plannerview | | AdhocReport |
| (Frontend) | | Controller |
+------------------+ +--------------------+
| |
| 1. GET /v1/adhoc/fields?meetingId=X |
|----------------------------------------->| systemFields()
|<-----------------------------------------| => AdhocReportFieldStorage
| [34 system fields + custom fields] | + ReportFieldMapper
| |
| 2. User drag-drop fields to build report |
| |
| 3. POST /v1/adhoc |
| {meetingId, reportName, |
| reportField: [{label, sequence}]} |
|----------------------------------------->| saveAdhoc()
| | => INSERT t_adhoc_report
| | => INSERT t_adhoc_report_detail (per field)
| |
| 4. GET /v1/adhoc/run?id=X&meetingId=Y |
|----------------------------------------->| listAdhocReportDetails()
| | => AttendeeMapper.listAttendeeRegistrations()
| | => Map fields dynamically
|<-----------------------------------------|
| [Paginated attendee data with |
| selected fields only] |
| |
| 5. GET /v1/adhoc/run/download |
|----------------------------------------->| adhocReportDownload()
| | => DynamicField-based Excel export
|<-----------------------------------------|
| [Excel file download] |3.1.3 Custom Reports 流程(R/Python 脚本执行)
+------------------+ GET /v1/products/{id}/ +------------------------+
| Salesview | custom-reports | ProductCustomReport |
| (Frontend) |----------------------------->| Controller |
+------------------+ +------------------------+
| |
| listReports() v
|<------------------------------+ Scan filesystem for .R/.py files
| [report name, comment, | Parse first 2 lines:
| permitted roles] | Line 1: # comment/description
| | Line 2: # ROLE1,ROLE2 (permissions)
| + Filter by user roles
|
| GET /v1/products/{id}/custom-reports/{name}
|----------------------------->|
| | download()
| v
| +-------------------------+
| | 1. Run SQL data extract |
| | (COPY TO CSV via |
| | PGConnection) |
| +-------------------------+
| |
| +-------------------------+
| | 2. Some reports: |
| | generate input data |
| | via Java (e.g. |
| | SpeakerProgramCost |
| | Summary, Verona) |
| +-------------------------+
| |
| +-------------------------+
| | 3. Execute script: |
| | Rscript <file>.R |
| | or python3 <file>.py |
| | (ProcessBuilder) |
| +-------------------------+
| |
| +-------------------------+
| | 4. Find OUTPUT files: |
| | "OUTPUT - {name}.xlsx"|
| | Single: download |
| | Multiple: ZIP |
| +-------------------------+
| |
|<-----------------------------|
| [File download] |3.1.4 导出引擎流程
+------------------+
| ExcelService |
| .export(list, |
| fileName) |
+------------------+
|
v
+---------------------------+
| 1. Reflection: scan |
| @ExcelColumn fields |
| on entity class |
+---------------------------+
|
v
+---------------------------+
| 2. isOwnColumn() check: |
| - optional: check |
| agencyConfig |
| - excludeByProperty: |
| check ProductConfig |
| - includeByProperty: |
| check ProductConfig |
+---------------------------+
|
v
+---------------------------+
| 3. Build header map |
| (colIndex -> title) |
| Build data rows |
| (colIndex -> value) |
+---------------------------+
|
v
+---------------------------+
| 4. Handle DynamicField |
| (ExcelEntity subclass) |
| for runtime columns |
+---------------------------+
|
v
+---------------------------+
| 5. Generate Workbook |
| (SXSSFWorkbook/HSSF) |
| or CSV/TXT |
+---------------------------+
|
v
+---------------------------+
| 6. Write to HTTP response |
| or server filesystem |
+---------------------------+3.2 Validation Rules
| 规则 | 位置 | 说明 |
|---|---|---|
| 报告名唯一性 | AdhocReportService.java:128-141 | 同一 meeting 下 Ad-hoc 报告名称不能重复,保存前调用 checkAdhocName |
| 报告字段非空 | AdhocReportRequestDTO.java:19 | @NotEmpty 注解,报告必须至少选择一个字段 |
| meetingId 非空 | AdhocReportRequestDTO.java:14 | @NotNull 注解 |
| reportName 非空 | AdhocReportRequestDTO.java:17 | @NotBlank 注解 |
| 权限范围过滤 | ProductReportService.java:266-278 | 根据用户角色(Product/Region/District)自动过滤数据范围 |
| 空数据校验 | ProductReportService.java:281-283 | 导出时若查询结果为空,抛出 NOT_FOUND 异常 |
| Custom Report 角色权限 | ProductCustomReportService.java:86-98 | R/Python 脚本第二行定义允许角色,运行时校验用户角色 |
3.3 Business Logic Issues
权限过滤逻辑大量重复 (
ProductReportService.java:266-278, 538-551, 569-583, 634-651, 703-724)- 至少 8 个方法中重复了相同的 Product/Region/District 权限判断逻辑。应抽取为通用方法。
目标匹配依赖文件系统 (
ProductReportService.java:1246-1273)loadTargetLookupMaps()方法从文件系统读取targets.xlsx文件来判断 attendee 是否为 target,而不是查询数据库。每次报告生成都要重新读取解析 Excel 文件。
Noven 客户特定逻辑硬编码 (
ProductReportService.java:429-436)- 多个 Noven 客户特定的字段映射硬编码在代码中(如
"Will you be joining live or virtually?"、"URO/ONC"),违反了多租户配置化原则。
- 多个 Noven 客户特定的字段映射硬编码在代码中(如
Custom Report 通过 ProcessBuilder 执行外部脚本 (
ProductCustomReportService.java:314-324)- 安全风险:执行服务器上的 R/Python 脚本,脚本内容完全由文件系统控制。
- 可靠性:依赖服务器安装 R 和 Python 环境。
- 报告名硬编码分支处理:
"Speaker Program Cost Summary"、"Verona Aggregate Spend Report"、"Tracking Campaign Stats"三个报告名在代码中用if-else特殊处理 (ProductCustomReportService.java:159-256)。
Data Extract 通过 PostgreSQL COPY 命令直接导出 (
ProductCustomReportService.java:348-394)- 使用
PGConnection.getCopyAPI()执行原始 SQL 文件导出 CSV,SQL 文件存储在文件系统的dataextract目录。这绕过了所有权限控制和数据过滤。
- 使用
4. API Inventory
4.1 REST Endpoints Table
ProductReportController (v1/products/{productId}/reports)
| HTTP Method | Path | 方法 | 说明 | 返回类型 |
|---|---|---|---|---|
| GET | /at-a-glance | getAtAGlance | 概览仪表板 | AtAGlanceResponse |
| GET | /program-activity/program-summary | exportPrograms | 项目摘要下载 (Excel) | void (file) |
| GET | /program-activity/dashboard | programActivityDashboard | 项目活动仪表板 | ProgramActivityDashboardResponse |
| GET | /program-activity/dashboard-program-activity-summary | getProgramActivitySummary | 按品牌的活动摘要 | ProgramActivitySummaryResponse |
| GET | /program-activity/dashboard-program-activity-by-brand | dashboardProgramActivityByBrand | 按品牌跨年度的活动分析 | ProgramActivitySummaryResponse |
| GET | /program-activity/fiscal-ytd-spend-by-brand | getFiscalYearToDateSpendByBrand | 年度品牌花费 | List<FiscalYearToDateSpendResponse> |
| GET | /program-activity/fiscal-ytd-operated-future-programs | getFiscalYtdOperatedFuturePrograms | 年度已执行/未来项目 | List<FiscalYearToDateProgramsResponse> |
| GET | /program-activity/programs-by-budget-status | getProgramsByBudgetStatus | 按预算状态的项目分布 | List<ProgramsByStatusResponse> |
| GET | /program-activity/geography-and-spend | getProgramActivitiesByGeographySpend | 地理区域花费 | List<GeographySpendResponse> |
| GET | /program-activity/geography-and-spend/download | downloadProgramActivities | 地理区域花费下载 | void (file) |
| GET | /program-activity/geography-and-spend/program-type-details | getGeographySpendProgramTypeDetails | 地理区域项目类型详情 | List<GeographySpendProgramTypeDetailsResponse> |
| GET | /program-activity/geography-and-spend/program-type-details/download | downloadGeographySpendDistrictDetails | 地理区域项目类型详情下载 | void (file) |
| GET | /program-activity/geography-and-spend/program-details | getGeographySpendProgramDetails | 地理区域项目详情 | List<GeographySpendProgramDetailsResponse> |
| GET | /program-activity/geography-and-spend/program-details/download | downloadGeographySpendProgramDetails | 地理区域项目详情下载 | void (file) |
| GET | /program-activity/sales-representatives | getProgramActivitiesBySalesRep | 按销售代表的活动 | List<ActivityBySalesRepResponse> |
| GET | /program-activity/sales-representatives/download | downloadProgramActivitiesBySalesRep | 按销售代表的活动下载 | void (file) |
| GET | /program-activity/sales-representatives/{userId}/programs | getProgramsBySalesRep | 特定销售代表的项目列表 | List<ProgramsBySalesRepResponse> |
| GET | /program-activity/sales-representatives/{userId}/programs/download | downloadProgramsBySalesRep | 特定销售代表项目下载 | void (file) |
| GET | /program-activity/speaker-utilization | listSpeakerUtilization | Speaker 利用率 | List<SpeakerUtilizationResponse> |
| GET | /program-activity/speaker-utilization/download | downloadSpeakerUtilization | Speaker 利用率下载 | void (file) |
| GET | /program-activity/speaker-utilization-detail | listSpeakerUtilizationDetail | Speaker 利用率详情 | List<SpeakerUtilizationDetailResponse> |
| GET | /program-activity/speaker-utilization-detail/download | downloadSpeakerUtilizationDetail | Speaker 利用率详情下载 | void (file) |
| GET | /attendee-analytics/dashboard | attendeeDashboard | 参会者分析仪表板 | AttendeeAnalyticsDashboardResponse |
| GET | /attendee-analytics/attendeetrackers | downloadAttendeeTrackers | 参会者追踪下载 (Excel) | void (file) |
| GET | /attendee-analytics/registration-summary | getRegistrationSummary | 注册摘要 | List<RegistrationSummaryResponse> |
| GET | /attendee-analytics/target-reach-by-specialty | getTargetReachBySpecialty | 按专业的目标覆盖 | List<TargetReachBySpecialtyResponse> |
| GET | /attendee-analytics/registration-by-geography | getRegistrationByGeography | 按地理的注册分布 | List<RegistrationByGeographyResponse> |
| GET | /attendee-analytics/registration-by-program | getRegistrationByProgram | 按项目的注册分布 | List<RegistrationByProgramResponse> |
| GET | /compliance-audit-list | getComplianceAuditList | 合规审计列表 | List<ComplianceAuditResponse> |
总计: 29 个端点
ProductCustomReportController (v1/products/{productId}/custom-reports)
| HTTP Method | Path | 方法 | 说明 | 返回类型 |
|---|---|---|---|---|
| GET | / | listReports | 列出可用的自定义报告 | List<CustomReportResponse> |
| GET | /{reportName} | download | 执行并下载自定义报告 | void (file) |
总计: 2 个端点
AdhocReportController (v1/adhoc)
| HTTP Method | Path | 方法 | 说明 | 返回类型 |
|---|---|---|---|---|
| GET | / | listAdhocReports | 分页列出 Ad-hoc 报告 | PageResult<AdhocReport> |
| GET | /{id} | adhocDetail | 获取 Ad-hoc 报告详情 | AdhocResponseDTO |
| GET | /fields | fields | 获取可用报告字段 | List<ReportFieldDTO> |
| POST | / | saveAdhoc | 保存/更新 Ad-hoc 报告 | void |
| GET | /check | checkAdhocName | 检查报告名唯一性 | List<AdhocReport> |
| DELETE | /{id} | deleteAdhoc | 删除 Ad-hoc 报告 | void |
| GET | /run | adhocRunList | 运行 Ad-hoc 报告(分页) | PageResult<ListAdhocReportDetailsResponse> |
| GET | /run/download | adhocReportDownload | 下载 Ad-hoc 报告 Excel | void (file) |
总计: 8 个端点
ComplianceController (v1/compliance) - 报告相关部分
| HTTP Method | Path | 方法 | 说明 | 返回类型 |
|---|---|---|---|---|
| GET | / | listCompliance | 合规列表 | PageResult |
| GET | /export | export | 合规数据导出 | void (file) |
| GET | /{attendeeId}/profile | profile | 参会者合规详情 | ComplianceProfileResponse |
| GET | /configuration | getAggregateSpendConfiguration | 聚合花费报告配置 | List<AggregateSpendConfiguration> |
| POST | /configuration | saveAggregateSpendConfiguration | 保存配置 | void |
| GET | /field-mappings | getAllFieldMappings | 字段映射列表 | List<AggregateSpendFieldMapping> |
| POST | /field-mappings | addFieldMapping | 添加字段映射 | void |
| DELETE | /field-mappings | deleteFieldMapping | 删除字段映射 | void |
总计: 8 个端点
所有报告相关端点总计: 47 个
4.2 API Design Issues
下载端点使用 GET 方法但返回 void - 所有 download 端点(约 15 个)使用 GET 请求方法但直接操作
HttpServletResponse写文件流。虽然功能正确,但违反 RESTful 设计:GET 应该是幂等的数据查询,文件下载应使用 POST 或至少明确标注produces类型。报告查询参数通过 query string 传递复杂过滤条件 - 如
ProgramSummaryQuery包含多个 List 类型参数(regionIds, districtIds),通过 Spring MVC 的自动绑定虽然能工作,但不如 POST + RequestBody 清晰。缺乏统一的报告 API 命名规范 - 混用
download、export、run/download、直接 GET(返回 void)等多种下载模式。Ad-hoc 报告的
check端点设计 (AdhocReportController.java:63-67) - 使用 GET 返回同名报告列表,前端需要额外判断。应设计为返回布尔值的校验端点。Controller 层缺乏 @ApiOperation 注解一致性 -
ProductReportController的getComplianceAuditList方法(第 279 行)缺少@ApiOperation注解。
5. Frontend Analysis
5.1 Pages & Components
Salesview 报告页面结构(核心报告 UI 所在)
pharmagin-salesview/src/pages/Reports/
├── index.js # 主路由入口 + Redux/Saga 注入
├── Navigation.js # 左侧导航菜单
├── menus.js # 菜单配置(权限过滤)
├── saga.js # 顶层 saga(At-a-Glance, Attendee Dashboard, Download 等)
├── reducer.js # 顶层 reducer
├── actions.js # 顶层 actions
├── selectors.js # 顶层 selectors
│
├── Glance/ # At a Glance 仪表板
│ ├── index.js # 4 个指标卡片 + 图表
│ ├── Filters.js # 年份筛选器
│ └── Loadable.js # React.lazy 包装
│
├── ProgramActivity/ # 项目活动报告(最复杂的子模块)
│ ├── index.js # 子路由分发(11 个子路由)
│ ├── actions.js # 18 个 Redux actions (createRoutine)
│ ├── reducer.js # 13 个状态字段
│ ├── saga.js # 18 个 saga workers
│ ├── selectors.js # Redux selectors
│ ├── constants.js # 查询参数常量
│ └── components/
│ ├── Dashboard/ # 仪表板(Program Spend, Booked Programs, Spend vs Budget)
│ ├── DashboardProgramActivitySummary.js
│ ├── DashboardProgramActivityByBrand.js
│ ├── ProgramSummaryReport.js # 项目摘要报告下载
│ ├── ReportDownload.js # 通用下载组件
│ ├── FiscalYtdSpendByBrand.js
│ ├── FiscalYtdOperatedFuturePrograms.js
│ ├── ProgramsByBudgetStatus.js
│ ├── ProgramActivityByGeographyAndSpend.js
│ ├── ProgramActivityBySalesRepresentatives.js
│ ├── SpeakerUtilization.js
│ ├── GeographySpendReportList/ # 多层级钻取列表
│ │ ├── SpendReportDetailList/
│ │ └── ProgramDetailList/
│ ├── SalesRepReportList/ # 销售代表报告钻取
│ │ └── SalesReport/
│ ├── SpeakerUtilizationList/ # Speaker 利用率钻取
│ │ └── SpeakerUtilizationDetail/
│ ├── SpendByBrandList/
│ ├── ProgramsByBudgetStatusList/
│ └── FiscalPeriodToDateOperatedFutureProgramsList/
│
├── AttendeeAnalytics/ # 参会者分析
│ ├── AttendeeTracker.js # 参会者追踪下载
│ ├── Dashboard/ # 参会者仪表板(4 个图表)
│ ├── RegistrationSummary/ # 注册摘要表格
│ ├── RegistrationBySpecialty/# 按专业的目标覆盖
│ ├── RegistrationByGeography/# 按地理的注册分布
│ └── RegistrationByProgram/ # 按项目的注册分布
│
├── Survey/ # 调查问卷报告
│ ├── index.js # 调查列表
│ └── SurveyProfile.js # 调查详情和统计
│
├── OtherSurvey/ # 其他调查
│
├── CustomReports/ # 自定义报告
│ ├── index.js # 子路由(列表 + 运行)
│ ├── ReportsList.js # 报告列表(从硬编码 data.js 读取 demo 数据)
│ ├── RunReport.js # 运行报告分发
│ ├── ReportForm.js # 报告字段选择表单(拖拽)
│ ├── ProgramActivityReport.js # 项目活动类报告
│ ├── AttendeeReport.js # 参会者类报告
│ ├── data.js # 硬编码 demo 数据和字段定义
│ └── Filters.js # 筛选器
│
├── ComplianceAudit/ # 合规审计
│ ├── index.js # 审计列表
│ ├── List.js # 列表组件
│ ├── Detail.js # 详情页(嵌套 Tab)
│ ├── ProgramInfo.js # 项目信息
│ ├── Attendees.js # 参会者
│ ├── Budget.js # 预算
│ ├── TransferOfValue.js # 价值转移
│ ├── Documentation.js # 文档清单
│ ├── DocumentationFileList.js
│ └── Filters.js # 筛选器
│
└── Form/
└── DownloadForm.js # 通用下载表单组件Salesview 报告组件总计: ~80+ 个文件
Plannerview Ad-hoc Report 组件
pharmagin-plannerview/legacy/src/components/AdhocReport/
├── index.js # 主组件(Ad-hoc 报告管理 + 运行)
├── AdhocReportForm.js # 报告创建/编辑表单(拖拽字段选择)
├── actions.js # 10 个 Redux actions
├── reducer.js # Redux reducer
├── sagas.js # 8 个 saga workers
├── selectors.js # Redux selectors
└── Drag/
├── Card.js # 拖拽卡片组件
├── Container.js # 拖拽容器
└── SystemFieldBox.js # 系统字段面板Plannerview Ad-hoc 报告组件总计: 9 个文件
Speakerview 报告功能
Speakerview 中没有独立的报告模块。唯一与报告相关的是 Speaker Detail 页面中的部分统计展示(如 speaker 的项目历史),不属于报告领域。
5.2 Redux State Structure
Salesview Reports 顶层 State (key: 'reports')
{
reports: {
glanceDashboard: {}, // At a Glance 数据
attendeeDashboard: {}, // Attendee Analytics Dashboard
registrationSummary: [], // 注册摘要
registrationBySpecialty: [], // 按专业统计
registrationByGeography: [], // 按地理统计
registrationByProgram: [], // 按项目统计
surveys: {}, // 调查列表
surveyResult: {}, // 调查结果
}
}Salesview ProgramActivity State (key: 'reports_program_activity')
{
reports_program_activity: {
ytdSpendByBrandData: [], // 年度品牌花费
programsByBudgetStatus: [], // 预算状态项目分布
geographySpendReport: [], // 地理花费
geographySpendReportDetail: [], // 地理花费-项目类型详情
geographySpendProgramDetail: [], // 地理花费-项目详情
allSalesReportList: [], // 全部销售代表报告
salesReportList: [], // 特定销售代表报告
speakerUtilizationReport: [], // Speaker 利用率
speakerUtilizationDetail: [], // Speaker 利用率详情
fiscalPeriodToDateOperatedReport: [], // 年度项目
activitySummaryData: {}, // 活动摘要
activitySummaryDataByBrand: {}, // 按品牌活动摘要
programsDashboard: {}, // 仪表板
}
}Plannerview AdhocReport State (key: 'adhocReport')
{
adhocReport: {
adhocLoading: false,
adhocData: {}, // 报告列表 (paginated)
adhocDetail: {}, // 当前报告详情
adhocDetailUUID: '', // 表单重新渲染 key
adhocModalVisible: false, // 编辑弹窗可见性
systemFields: [], // 可选字段列表
attendeeTypes: [], // 参会者类型(过滤)
runData: {}, // 运行结果数据 (paginated)
}
}5.3 Frontend Issues
CustomReports 的 data.js 包含硬编码 demo 数据 (
salesview/src/pages/Reports/CustomReports/data.js:1-254)getReportsData()返回 2 条硬编码 demo 记录("Program Activity Demo"、"Attendee Demo")。getProgramActivityFields()返回 42 个硬编码字段定义。getAttendeeFields()返回 46 个硬编码字段定义。getProgramActivityReportData()和getAttendeeReportData()返回各 5 条硬编码样本数据。- 这个"Custom Reports"页面实际上是一个未完成的原型/demo 功能,并没有连接到后端 API。
ReportsList.js直接从data.js读取数据,没有任何 API 调用。
Salesview 的 CustomReports 与后端 ProductCustomReportController 不是同一个功能
- 前端
CustomReports/ReportsList.js使用getReportsData()硬编码数据,是一个拖拽式报告构建器 demo。 - 后端
ProductCustomReportController是 R/Python 脚本执行器。 - 真正调用后端 custom-reports API 的前端代码不在 CustomReports 目录中,而是通过 Navigation 菜单中
enableCustomReport配置开关控制的独立功能。
- 前端
双重 Redux Store - 报告模块使用了 2 个独立的 Redux store key(
reports和reports_program_activity),增加了状态管理复杂度。两个 saga 文件独立运行,共计 28 个 saga worker。Plannerview Ad-hoc Report 使用过时的 React 模式 (
plannerview/legacy/src/components/AdhocReport/index.js)- 使用
this.refs.adhocReportForm(string refs,React 16.3+ 已废弃)。 - 使用
href="javascript:void(0);"模式。 - 使用 class component 而非 hooks。
- 直接调用
this.props.dispatch()而非mapDispatchToProps中的 action creators。
- 使用
前端大量重复的 download saga 逻辑 (
salesview/src/pages/Reports/ProgramActivity/saga.js)- 18 个 saga worker 中,download 类的 saga 与 data-fetch 类的 saga 代码结构完全相同,仅 URL 和 action 不同。应抽取为通用 saga 工厂函数。
Ad-hoc 报告的 saveName 校验重复执行 (
plannerview/legacy/src/components/AdhocReport/sagas.js:105-121)- 前端 saga 中在 POST 保存之前先调用 GET
/v1/adhoc/check校验名称唯一性,但后端saveAdhoc方法内部也做了相同的校验。双重校验浪费了一次网络请求。
- 前端 saga 中在 POST 保存之前先调用 GET
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| # | 问题 | 严重程度 | 影响范围 | 相关文件 |
|---|---|---|---|---|
| C1 | 报告框架碎片化严重 - 存在 4 种完全不同的报告机制:(1) 标准报告 (ProductReportService)、(2) Ad-hoc 报告 (AdhocReportService)、(3) Custom R/Python 脚本报告 (ProductCustomReportService)、(4) Data Extract 直接 SQL 导出 (DataExtractService)。没有统一的报告框架或抽象层。 | 严重 | 全局 | 所有 report 模块 |
| C2 | Custom Reports 执行任意脚本 - ProcessBuilder 执行文件系统上的 R/Python 脚本,存在安全风险(命令注入、权限提升)。脚本内容不受版本控制,难以审计。 | 严重 | ProductCustomReportService.java:314-324 | 安全 |
| C3 | Data Extract 绕过权限控制 - runDataExtractReport 使用 PGConnection 直接执行 SQL 文件,不经过任何用户权限过滤。SQL 文件来自文件系统,可能导出敏感数据。 | 严重 | ProductCustomReportService.java:348-394 | 安全 |
| C4 | CustomReports 前端是未完成的 demo - salesview 的 Custom Reports 页面使用硬编码数据,不连接后端 API。这意味着用户看到的是无用的原型界面。 | 严重 | salesview/src/pages/Reports/CustomReports/data.js | 前端 |
| C5 | 目标匹配依赖文件系统 Excel - targets.xlsx 文件用于判断 attendee 是否为 target,每次报告生成都重新读取解析,且文件内容无版本控制。 | 严重 | ProductReportService.java:1246-1273 | 数据 |
6.2 Design Defects (should improve)
| # | 问题 | 严重程度 | 影响范围 | 相关文件 |
|---|---|---|---|---|
| D1 | 权限过滤逻辑重复 8+ 次 - Product/Region/District 三级权限判断代码在 ProductReportService 中至少重复了 8 次。 | 中等 | ProductReportService.java | 代码质量 |
| D2 | Ad-hoc 字段系统硬编码 34 个字段 - AdhocReportFieldStorage 使用静态 Map 存储字段映射,无法通过配置扩展。 | 中等 | AdhocReportFieldStorage.java:11-50 | 可维护性 |
| D3 | 客户特定逻辑硬编码 - Noven 客户特定的参会者字段映射(如 "Will you be joining live or virtually?"、"URO/ONC")硬编码在 downloadAttendeeTrackers 方法中。 | 中等 | ProductReportService.java:429-436 | 多租户 |
| D4 | 报告名称硬编码的 if-else 分支 - Custom Report 下载中 "Speaker Program Cost Summary"、"Verona Aggregate Spend Report"、"Tracking Campaign Stats" 三个报告名使用 if-else 特殊处理。 | 中等 | ProductCustomReportService.java:159-256 | 可维护性 |
| D5 | Map<String, Map<String, Object>> 类型嵌套过深 - 多个 Response 使用 3 层嵌套 Map 作为数据结构(如 AtAGlanceResponse、ProgramActivityDashboardResponse),类型不安全,前端解析困难。 | 中等 | ProductReportService.java:164-230 | API 设计 |
| D6 | 双重 Redux Store - 报告模块使用 2 个独立的 store key + 2 个 saga 文件,共 28 个 saga worker,大量重复模板代码。 | 中等 | salesview Reports/ | 前端架构 |
| D7 | AdhocReportDetail 双 @Id 注解 - 可能导致 MyBatis 操作异常。 | 中等 | AdhocReportDetail.java:10-14 | 数据模型 |
| D8 | ExcelService 同步导出阻塞请求线程 - 大报告导出时整个过程(数据库查询 + Excel 生成 + HTTP 写入)全部在请求线程中同步完成,可能导致线程超时。 | 中等 | ExcelService.java | 性能 |
6.3 Technical Debt (nice to have)
| # | 问题 | 影响 | 相关文件 |
|---|---|---|---|
| T1 | Plannerview Ad-hoc Report 使用废弃的 React API - string refs、javascript:void(0)、class components | 可维护性 | AdhocReport/index.js |
| T2 | Download saga 模板代码重复 - 18 个 saga worker 可抽取为通用工厂 | 可维护性 | ProgramActivity/saga.js |
| T3 | 前后端校验重复 - Ad-hoc 报告名称唯一性在前端 saga 和后端 service 中都做了校验 | 性能 | sagas.js:108-121, AdhocReportService.java:128-141 |
| T4 | ExcelColumn 注解的 optional/include/exclude 机制复杂 - 字段可见性由 3 种不同机制控制(optional 配置、includeByProperty、excludeByProperty),增加理解难度 | 可维护性 | ExcelService.java:205-255 |
| T5 | Response 类型爆炸 - report 模块有 30+ 个独立的 Response/Download Response 类,很多只是字段子集差异 | 代码量 | report/response/ |
| T6 | GlobalSessionStorage 线程安全隐患 - ExcelService 使用 GlobalSessionStorage.setIds() 来传递 productId/companyId 给注解处理逻辑,这是 ThreadLocal 模式,但在异步场景下可能出错 | 稳定性 | ProductReportService.java:261,459 |
| T7 | DataExtractService 15 个无过滤器的全量查询 - 每个方法直接查全表,适合小数据量但扩展性差 | 性能 | DataExtractService.java |
| T8 | survey 报告功能混在 Reports 模块中 - Survey 统计逻辑属于 Survey 领域,不应耦合在 Reports 路由和 Redux store 中 | 架构 | salesview Reports/Survey/ |
7. Rewrite Recommendations
7.1 统一报告框架设计
目标: 将 4 种碎片化的报告机制统一为一个可扩展的报告框架。
ReportEngine (新框架)
├── ReportDefinition # 报告定义(模板元数据)
│ ├── StandardReport # 预定义报告(替代 ProductReportService 的各方法)
│ ├── AdhocReport # 用户自定义报告(替代 AdhocReportService)
│ └── ScriptReport # 脚本报告(替代 ProductCustomReportService,但沙箱化)
├── ReportDataSource # 数据源抽象
│ ├── SqlDataSource # MyBatis/JPA 查询
│ └── FileDataSource # 文件系统数据源
├── ReportExporter # 导出器
│ ├── ExcelExporter # XLSX 导出
│ ├── CsvExporter # CSV 导出
│ └── PdfExporter # PDF 导出(未来扩展)
├── ReportPermission # 权限过滤(统一的 Product/Region/District 过滤)
└── ReportScheduler # 异步报告生成 + 通知7.2 关键改进建议
废弃 R/Python 脚本执行机制 - 将所有脚本报告的逻辑迁移到 Java 服务端或使用沙箱化的报告模板引擎(如 JasperReports)。
异步报告生成 - 大报告应采用异步模式:提交报告任务 -> 后台生成 -> 完成后通知用户下载。避免同步阻塞请求线程。
统一权限过滤 - 将 Product/Region/District 三级权限过滤抽取为
ReportPermissionFilter,在报告框架层统一应用。目标匹配数据库化 - 将
targets.xlsx文件导入数据库表,通过 SQL JOIN 实现目标匹配,而不是每次读取文件。前端报告框架统一 - 将 2 个 Redux store 合并,使用通用的报告 hooks/HOC 减少重复代码。移除 CustomReports demo 代码或实现真正的功能。
Ad-hoc 报告字段配置化 - 将
AdhocReportFieldStorage的硬编码字段迁移到数据库配置,支持动态扩展。移除客户硬编码 - 将 Noven 特定字段映射迁移到
pharmagin-config-repo配置文件或数据库。Response 类型简化 - 使用泛型 Response 包装器 + 字段投影机制替代 30+ 个独立的 Response 类。考虑使用 GraphQL 或类似机制让前端指定需要的字段。