Analytics & Report Platform
Unified multi-tenant analytics — from live grid reports to ad-hoc AnalysisQuery, multisource KPI feeds, and performance intelligence
Platform Philosophy
The GB5 Analytics Platform is a unified, multi-tenant, streaming-first reporting infrastructure. It handles everything from simple paginated grids to cross-tabulated PDF exports generated asynchronously, to fully ad-hoc AnalysisQuery execution with live column designers, to ingestion of real-time KPI signals from PERM, OKR, and external data sources via Dapr pub/sub.
Zero string concatenation. All queries parameterized Dapper statements. AnalysisQuery uses a safe column-whitelist before any dynamic generation.
Report data consumed as IAsyncEnumerable — 100k+ row exports never fully loaded into memory. PDF/Excel streamed directly to response.
Async threshold, export formats, datasource preference, pivot mode, timeout — all in MREPORTCONFIG. Zero code changes to tune a report.
MREPORTDATASOURCERULE routes each report to OLTP, Report DB, or Archive DB based on date-range rules — transparent to the caller.
Architecture Layers
IQueryExecutor pattern — consistent connection management, HybridCache support, and CancellationToken propagation throughout. Module DALs that previously called AnalysisDAL directly now call through AnalysisBLL interfaces.Report Modes
| REPORTVIEWTYPE | Mode | Data Shape to Frontend | Export Support |
|---|---|---|---|
| 0 | Standard (flat) | Paginated rows; column headers from MREPORTVIEWFIELDS; user sorts/filters in-browser | Excel (flat), PDF, CSV |
| 1 | Analysis | Dynamic SQL result; field list varies per AnalysisQuery; ad-hoc column composition | Excel (flat), CSV |
| 2 | Pivot | IsPivotView=true → BE cross-tabulated grid; IsPivotView=false → raw rows for FE drag-drop pivot UI | Excel (cross-tab), PDF (pivot-aware table), CSV (flat) |
| 3 | KPI Dashboard | KPI card tiles with value + trend + target vs actual; no pagination | Excel (KPI summary), PDF |
Async Decision Flow
ReportViewId + params
row estimate vs threshold
stream direct to response
TSYSJOB created
generates file, stores to blob
notification + download link
MultiSource Data Connectors
Analytics connects to multiple data sources beyond the primary OLTP database. Each source is registered in MREPORTDATASOURCE and selected at report-time via MREPORTDATASOURCERULE based on date range, module, and tenant configuration.
Supported Source Types
Primary transactional database per tenant. Routes via LoginDTO.DatabaseName. Covers all real-time operational queries.
Read replica or dedicated report database. Used for large exports and analytical queries to avoid OLTP contention. Configured per tenant in MREPORTDATASOURCE.
Long-retention cold storage. Selected automatically when query date range extends beyond archive threshold (default: 2 years). MREPORTDATASOURCERULE drives selection.
Alternate RDBMS per tenant/module. IQueryExecutor handles both SQL Server and PostgreSQL via LoginDTO.DatabaseType. Same Dapper queries; driver-aware execution.
DataSync-replicated tables (see ETL section). Used when cross-tenant or cross-region consolidation is needed. Registered as a separate MREPORTDATASOURCE entry.
Extensibility placeholder for REST-based data connectors (ERP integrations, CRM data). IDataSourceConnector interface already defined; no production connectors yet.
DataSource Schema
| Table | Purpose | Key Columns |
|---|---|---|
| MREPORTDATASOURCE | Registered data source configurations per tenant | DataSourceId, Name, SourceType (0=OLTP/1=ReportDB/2=Archive/3=PostgreSQL/4=DataSync), ConnectionStringKey (Vault path), IsDefault, TenantId |
| MREPORTDATASOURCERULE | Conditional routing rules — which source to use when | RuleId, DataSourceId, ReportViewId (nullable=applies to all), ModuleCode (nullable), DateRangeDays (if query spans >N days → use this source), Priority, TenantId |
ServerConfigCache resolves the key to a live connection string via VaultSharp. Strings are cached in-process for 60s with automatic rotation on error.DataSource Rule Evaluation
// DataSourceRuleEvaluator — selects best datasource for a given report+params
1. Load rules for ReportViewId (or global rules if none specific)
2. Filter: ModuleCode matches or is null
3. Filter: query date range exceeds DateRangeDays threshold (if set)
4. Sort by Priority ASC — first matching rule wins
5. Fallback: MREPORTDATASOURCE WHERE IsDefault=true AND TenantId=@t
6. Resolve connection string from Vault via ServerConfigCache.GetConnectionStringAsync(key)
Workspace Concept
A Workspace is a named, saveable AnalysisQuery configuration — a user's or team's persistent "analytical view" of a dataset. Workspaces capture: the selected DataSource, column configuration, applied filters, sort order, grouping, and optionally a scheduled refresh or export.
Workspace Schema
| Table | Column | Notes |
|---|---|---|
| TANALYSISWORKSPACE | WorkspaceId int PK | |
| WorkspaceName nvarchar(200) | User-visible name (e.g., "Q2 Sales Trend") | |
| AnalysisId int FK MANALYSIS | Base analysis definition this workspace is built on | |
| OwnerId int | EmployeeId who created/owns this workspace | |
| IsShared bit | Shared with all users in the tenant if true | |
| ColumnConfigJson nvarchar(max) | JSON: selected columns, ordering, visibility, widths | |
| FilterJson nvarchar(max) | JSON: applied filter conditions + values | |
| SortJson nvarchar(max) | JSON: sort column + direction | |
| TenantId int | Indexes: (OwnerId, TenantId), (AnalysisId, TenantId, IsShared) |
Personal Workspace
IsShared=false. Visible only to OwnerId. Allows individual analysts to save their "daily views" without polluting shared config. Unlimited personal workspaces per analysis.
Shared Workspace
IsShared=true. Visible to all users in the tenant. Created by ADMIN role. Represents org-wide standard views (e.g., "Finance Month-End Checklist", "HR Bell Curve View").
Workspace vs Report
Reports are static, code-driven views defined in MREPORT. Workspaces are dynamic, user-driven configurations layered on top of AnalysisQuery — no dev work required to create new views.
ETL & DataSync Platform
ETL Pipeline Architecture
GB5's ETL capability is a push-based, configurable pipeline that extracts data from registered sources (GB5 OLTP, external APIs, file uploads), transforms via configurable mapping rules, and loads into target tables. Used for: cross-tenant consolidation, historical migrations, and the DataSync module.
Extract
Source-specific connector reads data in chunks. IAsyncEnumerable<T> — never loads full dataset.Transform
METLCOLUMNMAP defines column-level transformations. Type coercion, default filling, computed fields, date normalization.Validate
Row-level validation rules from METLRULE. Failed rows → TETLERRLOG with RowData. Never abort; collect errors.Load
BulkInsertAsync for batches >100 rows. Upsert via ExecuteAsync for delta loads. Transaction per batch; rollback per batch on error.Audit
TETLJOBRUN row created per execution. Tracks: TotalRows, LoadedRows, ErrorRows, StartedOn, CompletedOn, Status.DataSync Module
DataSync lives inside GB5Framework (not a separate microservice) and orchestrates the replication of multi-tenant data across environments. It migrates and replaces the legacy Gb4Import replication service.
DataSync Database Schema (18 tables)
| Table Group | Tables | Purpose |
|---|---|---|
| Configuration | MSWDATASOURCE, MSWCONNECTION, MSWDATABASEMAP | Source/target DB registrations, connection configs, DB-to-DB routing maps |
| DDL & Provisioning | MSWDDLSCRIPT, MSWDDLSCRIPTDETAIL, MSWPROVISIONPLAN, MSWPROVISIONSTEP | Schema version scripts, automated DB provisioning plans with ordered steps |
| Sync Jobs | MSWSYNCRULE, MSWSYNCSCHEDULE, TSWSYNCRUN, TSWSYNCRUNDETAIL | Per-table sync rules (delta/full), scheduling, execution audit with row counts per table |
| Metadata | MSWMETADATASNAPSHOT, MSWMETADATACHANGE, MSWOBJECTMAP | Schema snapshots for drift detection; change records; object name mappings for renames |
| Upgrade | MSWUPGRADEPACKAGE, MSWUPGRADESTEP, TSWUPGRADERUN | Named upgrade bundles of DDL scripts; step-by-step execution with rollback support |
| Error Tracking | TSWSYNCERROR | Per-row errors during sync with SourceRowKey, ErrorText, RetryCount |
GB4 Import Migration
The legacy Gb4Import WCF service replicated product/customer/financial master data across GB4 tenants via a polling-based XML pipeline. DataSync replaces this with a modern push-based approach:
| Legacy Gb4Import | GB5 DataSync Equivalent |
|---|---|
| WCF service polling every 15 min | MSWSYNCSCHEDULE (configurable cron, default every 5 min) |
| XML serialization/deserialization | Direct Dapper parameter binding; no intermediate serialization |
| Hardcoded table list | MSWSYNCRULE per table — add/remove without code changes |
| Full table copy each run | Delta sync via MSWSYNCRULE.DeltaColumn (ModifiedOn/RowVersion) |
| No audit trail | TSWSYNCRUN + TSWSYNCRUNDETAIL: every run fully audited |
| Error → silent log file | TSWSYNCERROR + Dapr event + admin alert |
| Manual schema migration | MSWDDLSCRIPT + MSWUPGRADEPACKAGE: versioned, automated |
KPI Management Platform
KPI Management provides the framework for defining, categorizing, and tracking Key Performance Indicators across modules. KPIs are either manually entered, auto-computed from TGOAL/TAPPRAISAL data, or ingested from external sources via ETL.
KPI Schema
MKPI — KPI Master
| Column | Type | Notes |
|---|---|---|
| KPIId, KPICode, KPIName | int / nvarchar | Unique Code per tenant |
| KPICategory | smallint | 0=Financial, 1=Customer, 2=Process, 3=People, 4=OKR, 5=Custom |
| MeasureUnit | nvarchar(50) | %, $, count, days, score, etc. |
| AggregationType | smallint | 0=Sum, 1=Avg, 2=Max, 3=Min, 4=Count, 5=LastValue |
| Frequency | smallint | 0=Daily, 1=Weekly, 2=Monthly, 3=Quarterly, 4=Annual |
| TargetDirection | smallint | 0=HigherIsBetter, 1=LowerIsBetter, 2=TargetBand |
| LowerBand / UpperBand | decimal(18,4) nullable | For TargetDirection=2 (band target) |
| Status | smallint | 0=Inactive, 1=Active |
| TenantId | int | Index: (TenantId, KPICategory, Status). Unique: (KPICode, TenantId) |
TKPILIST — KPI List (Computed)
Stores periodically computed KPI values — either from module data (PERM, OKR) via Dapr events, or from SQL queries scheduled through the ETL pipeline.
| Column | Type | Notes |
|---|---|---|
| KPIListId, KPIId, Period, PeriodLabel | int / date / nvarchar | Period = start of the frequency window (month start, quarter start, etc.) |
| ActualValue / TargetValue | decimal(18,4) | TargetValue from MKPITARGET or overridden manually |
| Variance | decimal(18,4) | Computed: ActualValue - TargetValue (positive = above target for HigherIsBetter) |
| VariancePercent | decimal(5,2) | Computed: (Variance / TargetValue) × 100 |
| SourceType | smallint | 0=ManualEntry, 1=PERMFeed, 2=OKRFeed, 3=ETLImport, 4=SQLComputed |
| SourceRefId | int | AppraisalPlanId / OKRCycleId / ETLJobId based on SourceType |
| OUID / DeptId | int nullable | Org unit scope; null = company-wide KPI |
| TenantId | int | Indexes: (KPIId, TenantId, Period), (TenantId, Period, KPICategory) |
TKPIMANUAL — Manual KPI Entry
Allows HR / Finance / managers to enter KPI values directly for KPIs that cannot be auto-computed from system data (e.g., NPS score, customer satisfaction from survey, market share from external report).
| Column | Type | Notes |
|---|---|---|
| KPIManualId, KPIId, Period | int / date | Unique: (KPIId, Period, OUID, TenantId) |
| EnteredValue / Notes | decimal / nvarchar | |
| EnteredBy / EnteredOn / ApprovedBy | int / datetime | Approval workflow — ApprovedBy populated on approval |
| Status | smallint | 0=Draft, 1=PendingApproval, 2=Approved, 3=Rejected |
| OUID / TenantId | int |
MROLEKPI — Role-Based KPI Assignments
Maps which KPIs are relevant for which roles. Drives role-specific KPI dashboard tiles in ESS and manager views.
| Column | Type | Notes |
|---|---|---|
| RoleKPIId, RoleId, KPIId | int | RoleId = MJOBROLE. Unique: (RoleId, KPIId, TenantId) |
| IsDefault | bit | Shown on dashboard without user customization |
| DisplayOrder | smallint | Card sort order on dashboard |
| TenantId | int | Index: (RoleId, TenantId) |
AnalysisQuery Designer
AnalysisQuery is GB5's ad-hoc query builder — a no-code dynamic SQL composer that allows analysts and power users to define custom analytical queries without writing SQL directly. Queries are defined once in MANALYSIS and executed on-demand.
Analysis Schema
| Table | Purpose | Key Columns |
|---|---|---|
| MANALYSIS | Analysis definition — the base query template | AnalysisId, Code, Name, BaseTable, JoinsJson (JSON array of JOIN clauses), ConditionsJson (available WHERE params), TenantId |
| MANALYSISCOLUMN | Available columns for this analysis | AnalysisColumnId, AnalysisId, ColumnAlias (safe whitelist key), ColumnExpression (actual SQL column/expression), DataType, IsFilterable, IsGroupable, DisplayLabel, SortOrder |
| MANALYSISFILTER | Saved filter presets for an analysis | FilterId, AnalysisId, FilterName, FilterJson, OwnerId, IsShared, TenantId |
| TANALYSISWORKSPACE | User-saved workspace configurations (see Workspace section) | WorkspaceId, AnalysisId, OwnerId, ColumnConfigJson, FilterJson, SortJson, IsShared |
AnalysisQuery Execution Flow
whitelist of valid columns
reject any not in whitelist
from whitelisted ColumnExpressions only
parameterized conditions
selects OLTP / ReportDB
StreamAsync for large results
ColumnAlias (from the request) is only used as a dictionary key to look up ColumnExpression from the server-side whitelist. User-supplied aliases are never interpolated into SQL. All WHERE conditions remain parameterized Dapper parameters — no user value ever appears in the SQL string.// AnalysisQueryBLL.ExecuteAnalysisAsync — simplified safe column composition
var whitelist = await _dal.GetColumnsAsync(analysisId, login, ct);
// Validate: reject any request.Columns not in whitelist
var invalid = req.Columns.Where(c => !whitelist.ContainsKey(c)).ToList();
if (invalid.Any()) throw new ValidationException($"Invalid columns: {string.Join(',', invalid)}");
// Build SELECT using server-side expressions only — never req.Columns directly
var selectClauses = req.Columns.Select(c => $"{whitelist[c].ColumnExpression} AS [{c}]");
var sql = $"SELECT {string.Join(", ", selectClauses)} FROM {analysis.BaseTable} {analysis.JoinsJson}";
// Add WHERE params — all parameterized
if (req.Filters?.Any() == true)
foreach (var f in req.Filters)
sql += $" AND {whitelist[f.Column].ColumnExpression} {f.Operator} @{f.Column}";
sql += $" AND TenantId = @TenantId"; // mandatory tenant filter
Performance KPI Feed (PerfKPIFeed)
PerfKPIFeed is the analytics bridge for the Performance Management Platform — it ingests finalized appraisal scores and closed OKR cycles into the KPI analytics framework via Dapr pub/sub events. This keeps performance data current in the KPI dashboard without polling.
TPERFKPIFEED Schema
| Column | Type | Notes |
|---|---|---|
| PerfKPIFeedId | int PK | |
| FeedSource | smallint | 0=PERMAppraisal, 1=OKRCycle |
| SourceId | int | AppraisalPlanId or OKRCycleId |
| Period | date | Performance period end date |
| OUID / DeptId | int nullable | Org scope; null = company aggregate |
| MetricKey | nvarchar(100) | Identifies the specific metric: 'AVG_FINAL_SCORE', 'BELL_CURVE_DIST', 'OKR_COMPLETION_RATE', 'GOAL_ATTRITION_RATE' |
| MetricValue | decimal(18,4) | Numeric value of the metric |
| MetricDetailJson | nvarchar(max) nullable | Optional breakdown JSON: e.g., bell curve grade distribution array |
| IngestedOn | datetime | Dapr event processing timestamp |
| TenantId | int | Indexes: (SourceId, FeedSource, TenantId), (TenantId, Period, MetricKey) |
Dapr Subscribers
PerformanceKPISubscriber — subscribes to perm.score.finalized
// AnalyticsSL/Subscribers/PerformanceKPISubscriber.cs
[ApiController][Route("analytics/subscribe")]
public class PerformanceKPISubscriber : ControllerBase
{
[HttpPost("perm-score-finalized")]
[Topic("pubsub", "perm.score.finalized")]
public async Task<IActionResult> OnPermScoreFinalized(
[FromBody] PermScoreFinalizedEvent evt, CancellationToken ct)
{
// evt: { AppraisalPlanId, PeriodEnd, EmployeeCount, Scores, GradeDistribution, TenantId }
await _perfKpiBll.IngestPermScoresAsync(evt, ct);
return Ok();
}
[HttpPost("okr-cycle-closed")]
[Topic("pubsub", "okr.cycle.closed")]
public async Task<IActionResult> OnOkrCycleClosed(
[FromBody] OkrCycleClosedEvent evt, CancellationToken ct)
{
// evt: { OKRCycleId, PeriodEnd, TotalGoals, CompletedGoals, AverageProgress, TenantId }
await _perfKpiBll.IngestOkrCycleAsync(evt, ct);
return Ok();
}
}
PerfKPIFeedBLL — metric computation on ingest
// IngestPermScoresAsync — writes TPERFKPIFEED rows per metric per OUID
public async Task IngestPermScoresAsync(PermScoreFinalizedEvent evt, CancellationToken ct)
{
// Aggregate: company-level + per-OU breakdown
var metrics = new List<PerfKPIFeedDTO>();
// AVG_FINAL_SCORE — average FinalPercentage across all employees in plan
metrics.Add(new() { FeedSource=0, SourceId=evt.AppraisalPlanId, Period=evt.PeriodEnd,
MetricKey="AVG_FINAL_SCORE", MetricValue=evt.Scores.Average(s => s.FinalPercentage) });
// BELL_CURVE_DIST — store grade distribution as JSON detail
metrics.Add(new() { FeedSource=0, SourceId=evt.AppraisalPlanId, Period=evt.PeriodEnd,
MetricKey="BELL_CURVE_DIST",
MetricValue=evt.GradeDistribution.Count, // count of distinct grades
MetricDetailJson=JsonSerializer.Serialize(evt.GradeDistribution) });
// Per-OU: avg score per org unit
foreach (var ou in evt.Scores.GroupBy(s => s.OUID))
metrics.Add(new() { FeedSource=0, SourceId=evt.AppraisalPlanId, Period=evt.PeriodEnd,
OUID=ou.Key, MetricKey="AVG_FINAL_SCORE", MetricValue=ou.Average(s => s.FinalPercentage) });
await _dal.BulkInsertPerfKPIFeedAsync(metrics, login, ct);
// Propagate to TKPILIST for KPI ID mapped to AVG_FINAL_SCORE
await _kpiListBll.UpdateFromPerfFeedAsync(metrics, ct);
}
Dapr Topic Subscriptions (3 total)
| Topic | Source Module | Analytics Action | TPERFKPIFEED MetricKeys |
|---|---|---|---|
| perm.score.finalized | PERM | Ingest appraisal scores per org unit | AVG_FINAL_SCORE, BELL_CURVE_DIST, HIGH_PERFORMER_PCT, PIP_TRIGGER_COUNT |
| perm.appraisal.finalized | PERM | Update KPI period tracking | APPRAISAL_COMPLETION_RATE (finalized vs total in plan) |
| okr.cycle.closed | OKR | Ingest OKR cycle summary | OKR_COMPLETION_RATE, GOAL_ATTRITION_RATE, AVG_FINAL_PROGRESS, AT_RISK_GOAL_PCT |
API Endpoints Reference
Core Report Platform
| Method | Route | Cache | Notes |
|---|---|---|---|
| GET | /CommonReport/Report | NOT_REQUIRED | Main report endpoint; streams via IAsyncEnumerable |
| GET | /CommonReport/GetMyReports | USER_LEVEL 2 min | Async job inbox for completed reports |
| GET | /CommonReport/GetPivotConfig | CLIENT_LEVEL 10 min | Pivot column + row definitions from MREPORTVIEWFIELDS |
| POST | /CommonReport/ExecuteAsyncJob | null | Queues TSYSJOB for large async report generation |
| GET | /CommonReport/DownloadReport/{jobId} | NOT_REQUIRED | Streams completed async report file (blob → response) |
DataSource Management
| Method | Route | Cache |
|---|---|---|
| GET | /Analytics/DataSource/GetDataSourceList | CLIENT_LEVEL 15 min |
| POST | /Analytics/DataSource/SaveDataSource | null |
| GET | /Analytics/DataSource/GetDataSourceRuleList {ReportViewId?} | CLIENT_LEVEL 10 min |
| POST | /Analytics/DataSource/SaveDataSourceRule | null |
| DELETE | /Analytics/DataSource/DeleteDataSourceRule {RuleId} | null |
Workspace
| Method | Route | Cache |
|---|---|---|
| GET | /Analytics/Workspace/GetWorkspaceList {AnalysisId} | USER_LEVEL 5 min |
| GET | /Analytics/Workspace/GetWorkspace {WorkspaceId} | USER_LEVEL 5 min |
| POST | /Analytics/Workspace/SaveWorkspace | null |
| DELETE | /Analytics/Workspace/DeleteWorkspace {WorkspaceId} | null |
AnalysisQuery
| Method | Route | Cache |
|---|---|---|
| GET | /Analysis/GetAnalysisList | CLIENT_LEVEL 10 min |
| GET | /Analysis/GetAnalysisColumns {AnalysisId} | CLIENT_LEVEL 15 min — column whitelist |
| POST | /Analysis/Execute | NOT_REQUIRED — always live |
| POST | /Analysis/ExportAnalysis | null — triggers async export if large |
| POST | /Analysis/SaveAnalysis | null (admin only) |
| POST | /Analysis/SaveAnalysisColumn | null (admin only — extends whitelist) |
KPI Management
| Method | Route | Cache |
|---|---|---|
| GET | /Analytics/KPI/GetKPIList {Category?, Status?} | CLIENT_LEVEL 10 min |
| GET | /Analytics/KPI/GetKPI {KPIId} | CLIENT_LEVEL 10 min |
| POST | /Analytics/KPI/SaveKPI | null |
| DELETE | /Analytics/KPI/DeleteKPI {KPIId} | null |
| GET | /Analytics/KPIList/GetKPIValues {KPIId, PeriodFrom, PeriodTo} | NOT_REQUIRED |
| POST | /Analytics/KPIList/SaveManualKPIEntry | null |
| POST | /Analytics/KPIList/ApproveManualKPIEntry {KPIManualId} | null |
| GET | /Analytics/RoleKPI/GetRoleKPIList {RoleId} | ROLE_LEVEL 15 min |
| POST | /Analytics/RoleKPI/SaveRoleKPI | null |
| DELETE | /Analytics/RoleKPI/DeleteRoleKPI {RoleKPIId} | null |
| GET | /Analytics/KPI/GetKPIDashboard {RoleId?, OUID?, Period} | NOT_REQUIRED — live KPI values |
Performance KPI Feed
| Method | Route | Cache |
|---|---|---|
| GET | /Analytics/PerfKPI/GetPerfKPIFeed {FeedSource, SourceId} | CLIENT_LEVEL 5 min |
| GET | /Analytics/PerfKPI/GetPerfKPITrend {MetricKey, TenantId, PeriodFrom, PeriodTo} | NOT_REQUIRED — trend data |
| GET | /Analytics/PerfKPI/GetOUPerformanceSummary {OUID, Period} | CLIENT_LEVEL 5 min |
Sample Scenarios
Scenario A — Standard Report with DataSource Routing
GET /CommonReport/Report?ReportViewId=501&DateFrom=2023-01-01&DateTo=2025-12-31&Format=Excel
// AsyncDecisionEngine checks row estimate: ~250,000 rows > threshold (100k)
// → Queues TSYSJOB (JobId=8821)
// Response: { "IsAsync": true, "JobId": 8821, "Message": "Report is being generated..." }
// User checks My Reports after 2 min:
GET /CommonReport/GetMyReports
// Response includes: { JobId: 8821, Status: 2 (Completed), FileUrl: "/report/8821" }
// DataSource resolution that happened internally:
// 1. Load MREPORTDATASOURCERULE for ReportViewId=501
// 2. Query date range = 1095 days > rule.DateRangeDays = 730
// 3. Selected DataSourceId = 3 (Archive DB)
// 4. Resolved connection string from Vault path "gb5/finance/archive-db"
Scenario B — AnalysisQuery Designer (Sales Dashboard)
// Step 1: Load available columns for Sales Analysis (AnalysisId: 12)
GET /Analysis/GetAnalysisColumns?AnalysisId=12
// Returns whitelist: OrderDate, CustomerName, ProductCode, Revenue, Qty, Region, SalesRep
// Step 2: Execute ad-hoc query — user selected columns + filter
POST /Analysis/Execute
{
"AnalysisId": 12,
"Columns": ["OrderDate", "Region", "Revenue", "Qty"],
"Filters": [
{ "Column": "Region", "Operator": "=", "Value": "North" },
{ "Column": "OrderDate", "Operator": ">=", "Value": "2026-01-01" }
],
"GroupBy": ["Region"],
"AggregateColumns": { "Revenue": "SUM", "Qty": "SUM" },
"SortColumn": "Revenue", "SortDirection": "DESC"
}
// Backend builds: SELECT Region, SUM([o].[Revenue]) AS Revenue, SUM([o].[Qty]) AS Qty
// FROM [TORDER] o ... WHERE Region = @Region AND OrderDate >= @OrderDate AND TenantId = @TenantId
// Column expressions come from whitelist — user-supplied column names used only as keys
// Step 3: Save as workspace for reuse
POST /Analytics/Workspace/SaveWorkspace
{ "WorkspaceName": "North Region Q1 2026", "AnalysisId": 12, "IsShared": false,
"ColumnConfigJson": "[{\"col\":\"Region\",...}, ...]",
"FilterJson": "[{\"Column\":\"Region\",\"Value\":\"North\"}]" }
Scenario C — KPI Dashboard Setup for HR Role
| KPI | Category | Unit | Agg | TargetDirection | FeedSource |
|---|---|---|---|---|---|
| Avg Appraisal Score | People | Score (0-100) | Avg | HigherIsBetter | PERM Dapr event |
| OKR Completion Rate | OKR | % | Avg | HigherIsBetter | OKR Dapr event |
| Bell Curve (A-Grade %) | People | % | LastValue | TargetBand (10-20%) | PERM Dapr event |
| PIP Trigger Rate | People | % | Avg | LowerIsBetter | PERM Dapr event |
| Employee NPS | People | Score (-100 to 100) | LastValue | HigherIsBetter | Manual entry |
Scenario D — PerfKPIFeed: Dapr Event → TPERFKPIFEED → KPI Dashboard
AppraisalPlanId: 301
Payload: EmployeeCount=120, Scores[], GradeDist[]
AnalyticsSL receives event
Computes 4 MetricKeys
KPI dashboard auto-refreshes
-- TPERFKPIFEED rows inserted for AppraisalPlanId=301, PeriodEnd=2026-03-31:
| FeedSource | SourceId | MetricKey | MetricValue | MetricDetailJson |
|------------|----------|---------------------|-------------|-------------------------|
| 0 (PERM) | 301 | AVG_FINAL_SCORE | 72.4 | NULL |
| 0 (PERM) | 301 | BELL_CURVE_DIST | 5 | [{"grade":"A","pct":14.2},{"grade":"B","pct":38.1},...] |
| 0 (PERM) | 301 | HIGH_PERFORMER_PCT | 14.2 | NULL |
| 0 (PERM) | 301 | PIP_TRIGGER_COUNT | 6 | NULL |
-- TKPILIST updated for KPIId mapped to AVG_FINAL_SCORE:
| KPIId | Period | ActualValue | TargetValue | Variance | SourceType | SourceRefId |
|-------|------------|-------------|-------------|----------|------------|-------------|
| 801 | 2026-03-31 | 72.4 | 70.0 | +2.4 | 1 (PERM) | 301 |
Scenario E — ETL DataSync: GB4 Master Data Replication
// MSWSYNCRULE for MPRODUCT (delta sync — only rows modified in last N hours):
{
"SyncRuleCode": "MPRODUCT_DELTA",
"SourceDatabaseId": 1, // GB4 OLTP SQL Server
"TargetDatabaseId": 5, // GB5 DataSync mirror
"TargetTable": "MPRODUCT",
"SyncMode": 1, // Delta (0=Full)
"DeltaColumn": "MODIFIEDON", // Incremental watermark column
"BatchSize": 500,
"Schedule": "*/15 * * * *", // Every 15 min
"IsActive": true
}
// On schedule fire — QuartzJob:
// 1. Read last successful sync watermark from TSWSYNCRUN
// 2. Extract: SELECT * FROM MPRODUCT WHERE MODIFIEDON > @lastWatermark
// 3. IAsyncEnumerable — 500 rows per batch
// 4. Transform: apply MSWOBJECTMAP column renames (GB4 COLNAME → GB5 ColumnName)
// 5. Load: BulkInsertAsync on new rows; ExecuteAsync UPDATE for existing
// 6. TSWSYNCRUN row: { TotalRows: 42, LoadedRows: 42, ErrorRows: 0, Status: 2 }
Scenario F — Pivot Report: Appraisal Bell Curve by Grade × Department
GET /CommonReport/Report?ReportViewId=721&AppraisalPlanId=301&IsPivotView=true&GroupBy=Grade&CrossBy=Department
// BE pivot engine returns:
// Columns: Grade | IT | Finance | HR | Sales | Total
// Row A | 3 | 2 | 1 | 4 | 10
// Row B | 12 | 8 | 5 | 13 | 38
// Row C | 6 | 4 | 3 | 5 | 18
// Row D | 1 | 1 | 0 | 2 | 4
// Total | 22 | 15 | 9 | 24 | 70
// Same endpoint with Format=Excel → ClosedXML cross-tab xlsx with conditional formatting
// Same endpoint with Format=PDF → PuppeteerSharp rendered table with page header/footer
Caching Strategy
| Data | Level | TTL | Invalidation |
|---|---|---|---|
| MREPORT / MREPORTVIEW metadata | CLIENT_LEVEL | 15 min | SaveReport (admin) |
| MREPORTVIEWFIELDS (column config) | CLIENT_LEVEL | 15 min | SaveReportViewField |
| MREPORTDATASOURCERULE | CLIENT_LEVEL | 10 min | Save, Delete |
| MANALYSIS + MANALYSISCOLUMN | CLIENT_LEVEL | 15 min | SaveAnalysis, SaveAnalysisColumn |
| TANALYSISWORKSPACE list (user) | USER_LEVEL | 5 min | Save, Delete |
| Report execution results | NOT_REQUIRED | — | Always live — report data changes constantly |
| MKPI master list | CLIENT_LEVEL | 10 min | SaveKPI, DeleteKPI |
| MROLEKPI per role | ROLE_LEVEL | 15 min | SaveRoleKPI, DeleteRoleKPI |
| TKPILIST values | NOT_REQUIRED | — | Live — updated by Dapr events |
| TPERFKPIFEED feed entries | CLIENT_LEVEL | 5 min | On Dapr subscriber ingest |
| DataSync MSWSYNCRULE configs | CLIENT_LEVEL | 10 min | Save, Delete |
| ServerConfigCache (connection strings) | In-process Singleton | 60s | On VaultSharp read error (auto-rotate) |
Security & Multi-Tenancy
Multi-Tenancy Rules
DB-Level Isolation
LoginDTO.DatabaseName routes to tenant database. Analytics never has a WHERE TenantId filter on the same database — the database itself is the tenant boundary.
AnalysisQuery Scope
AnalysisQuery appends AND TenantId = @TenantId even within a single-tenant DB where AnalysisId is shared. Belt-and-suspenders approach for shared analytics frameworks.
Vault Connection Strings
Archive DB and Report DB connection strings stored in HashiCorp Vault. Connection string key stored in MREPORTDATASOURCE. Never in appsettings.json — even in dev.
Column Whitelist
AnalysisQuery never interpolates user input into SQL. User supplies column aliases only; server maps them to MANALYSISCOLUMN.ColumnExpression. Whitelist enforced server-side.
Role-Based Analytics Access
| Endpoint Group | Required Role | Notes |
|---|---|---|
| Standard reports | Any authenticated user | MREPORTVIEW.RoleAccess filter applies |
| AnalysisQuery execute | ANALYST or ADMIN | MANALYSIS.RoleAccess validates at execution |
| SaveAnalysis / SaveAnalysisColumn | ADMIN only | Column whitelist modification — admin-gated |
| SaveDataSource / Rules | SYSADMIN | Vault keys exposed — strictest gate |
| KPI master CRUD | HR_ADMIN or FINANCE_ADMIN | Category-based access — HR for People KPIs, Finance for Financial KPIs |
| KPI manual entry approval | HR_MANAGER or FINANCE_MANAGER | Segregated from entry (maker-checker) |
| GetKPIDashboard | Any + role filter | MROLEKPI filters dashboard tiles by role; no cross-role leak |
| PerfKPI feed endpoints | HR_ADMIN / HR_MANAGER | Aggregate performance data — HR access only |
Sensitive Data Rules
- Never log individual appraisal scores, bell curve data, or EmployeeId in log messages
- PerfKPIFeed stores aggregate metrics only — no individual employee data in TPERFKPIFEED
- AsyncJob file URLs are signed with a short-lived token (60 min) — not permanent download links
- DataSync connections encrypted in transit; never stored in plain text in any table
- TSWSYNCERROR never stores full row data for tables with sensitive columns (MEMPLOYEE, TPAYSLIP)