diff --git a/Core/Resgrid.Model/Reporting/AvailabilityClass.cs b/Core/Resgrid.Model/Reporting/AvailabilityClass.cs
new file mode 100644
index 000000000..2e074fa81
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/AvailabilityClass.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Canonical, cross-department operational availability classification for a person or unit.
+ /// Produced by from a built-in or custom status' base type.
+ /// This is the single vocabulary the Dispatch app and reporting use to answer
+ /// "is this resource available for a call?" regardless of a department's custom status labels.
+ ///
+ public enum AvailabilityClass
+ {
+ /// Status could not be mapped to a known base type.
+ [Description("Unknown")]
+ [Display(Name = "Unknown")]
+ Unknown = 0,
+
+ /// Available to be assigned/dispatched to a call.
+ [Description("Available")]
+ [Display(Name = "Available")]
+ Available = 1,
+
+ /// Engaged on a call/assignment and not available for another.
+ [Description("Committed")]
+ [Display(Name = "Committed")]
+ Committed = 2,
+
+ /// Out of service / not responding / unavailable.
+ [Description("Unavailable")]
+ [Display(Name = "Unavailable")]
+ Unavailable = 3,
+
+ /// Available but with a delay (e.g. delayed response).
+ [Description("Delayed")]
+ [Display(Name = "Delayed")]
+ Delayed = 4
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/AvailabilityMatrix.cs b/Core/Resgrid.Model/Reporting/AvailabilityMatrix.cs
new file mode 100644
index 000000000..078525bb8
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/AvailabilityMatrix.cs
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Hard-coded, NON per-department mapping of a resource's base status type to a canonical
+ /// . This is the single place the system knows what a base status
+ /// type *means* operationally (e.g. a "Responding" base type means the person/unit is not
+ /// available for another call).
+ ///
+ /// Status resolution rules (applied by the reporting service, not by SQL):
+ /// * Personnel current status lives on the latest ActionLog.ActionTypeId, which holds
+ /// EITHER a built-in value OR a CustomStateDetailId.
+ /// * Unit current status lives on the latest UnitState.State, which holds EITHER a
+ /// built-in value OR a CustomStateDetailId.
+ /// * A custom status maps to a canonical base via CustomStateDetail.BaseType
+ /// (an value), which then maps here via
+ /// / .
+ ///
+ /// NOTE: built-in enum values (0..n) overlap numerically with low CustomStateDetailId values, so
+ /// disambiguation is done by the service using the department's known set of custom state detail
+ /// ids (see PlatformReportingService) — NOT by numeric range. The helpers here only translate an
+ /// already-classified base/built-in value into an .
+ ///
+ public static class AvailabilityMatrix
+ {
+ /// Highest built-in value (personnel).
+ public const int MaxBuiltInActionType = (int)ActionTypes.OnUnit; // 7
+
+ /// Highest built-in value (units).
+ public const int MaxBuiltInUnitStateType = (int)UnitStateTypes.Enroute; // 12
+
+ // Canonical base type (ActionBaseTypes) -> availability. Used for custom statuses (personnel
+ // and units both store CustomStateDetail.BaseType as an ActionBaseTypes value).
+ private static readonly IReadOnlyDictionary ByActionBaseType = new Dictionary
+ {
+ { (int)ActionBaseTypes.None, AvailabilityClass.Unknown },
+ { (int)ActionBaseTypes.Available, AvailabilityClass.Available },
+ { (int)ActionBaseTypes.NotResponding, AvailabilityClass.Unavailable },
+ { (int)ActionBaseTypes.Responding, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.OnScene, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.MadeContact, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.Investigating, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.Dispatched, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.Cleared, AvailabilityClass.Available },
+ { (int)ActionBaseTypes.Returning, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.Staging, AvailabilityClass.Committed },
+ { (int)ActionBaseTypes.Unavailable, AvailabilityClass.Unavailable },
+ };
+
+ // Built-in personnel status (ActionTypes) -> availability.
+ private static readonly IReadOnlyDictionary ByActionType = new Dictionary
+ {
+ { (int)ActionTypes.StandingBy, AvailabilityClass.Available }, // "Available"
+ { (int)ActionTypes.NotResponding, AvailabilityClass.Unavailable },
+ { (int)ActionTypes.Responding, AvailabilityClass.Committed },
+ { (int)ActionTypes.OnScene, AvailabilityClass.Committed },
+ { (int)ActionTypes.AvailableStation, AvailabilityClass.Available },
+ { (int)ActionTypes.RespondingToStation, AvailabilityClass.Committed },
+ { (int)ActionTypes.RespondingToScene, AvailabilityClass.Committed },
+ { (int)ActionTypes.OnUnit, AvailabilityClass.Available }, // staffing a unit; unit state is authoritative in richer views
+ };
+
+ // Built-in unit status (UnitStateTypes) -> availability.
+ private static readonly IReadOnlyDictionary ByUnitStateType = new Dictionary
+ {
+ { (int)UnitStateTypes.Available, AvailabilityClass.Available },
+ { (int)UnitStateTypes.Delayed, AvailabilityClass.Delayed },
+ { (int)UnitStateTypes.Unavailable, AvailabilityClass.Unavailable },
+ { (int)UnitStateTypes.Committed, AvailabilityClass.Committed },
+ { (int)UnitStateTypes.OutOfService, AvailabilityClass.Unavailable },
+ { (int)UnitStateTypes.Responding, AvailabilityClass.Committed },
+ { (int)UnitStateTypes.OnScene, AvailabilityClass.Committed },
+ { (int)UnitStateTypes.Staging, AvailabilityClass.Committed },
+ { (int)UnitStateTypes.Returning, AvailabilityClass.Committed },
+ { (int)UnitStateTypes.Cancelled, AvailabilityClass.Available },
+ { (int)UnitStateTypes.Released, AvailabilityClass.Available },
+ { (int)UnitStateTypes.Manual, AvailabilityClass.Unknown },
+ { (int)UnitStateTypes.Enroute, AvailabilityClass.Committed },
+ };
+
+ /// Maps a canonical personnel base type () to availability.
+ public static AvailabilityClass ForPersonnelBaseType(int actionBaseType) =>
+ ByActionBaseType.TryGetValue(actionBaseType, out var c) ? c : AvailabilityClass.Unknown;
+
+ ///
+ /// Maps a custom status' CustomStateDetail.BaseType (an value)
+ /// to availability. Applies to both personnel and unit custom statuses.
+ ///
+ public static AvailabilityClass ForCustomBaseType(int actionBaseType) =>
+ ForPersonnelBaseType(actionBaseType);
+
+ /// Maps a built-in personnel status () to availability.
+ public static AvailabilityClass ForBuiltInPersonnelActionType(int actionType) =>
+ ByActionType.TryGetValue(actionType, out var c) ? c : AvailabilityClass.Unknown;
+
+ /// Maps a built-in unit status () to availability.
+ public static AvailabilityClass ForUnitStateType(int unitStateType) =>
+ ByUnitStateType.TryGetValue(unitStateType, out var c) ? c : AvailabilityClass.Unknown;
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/Breakdown.cs b/Core/Resgrid.Model/Reporting/Breakdown.cs
new file mode 100644
index 000000000..f0d0e6580
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/Breakdown.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// A grouped (GROUP BY) breakdown, capped to top-N + "Other". Examples keyed as
+ /// "callsByType", "callsByPriority", "callsByStatus", "personnelByState", "unitsByStatus".
+ ///
+ public class Breakdown
+ {
+ public string Key { get; set; }
+
+ public List Items { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/BreakdownItem.cs b/Core/Resgrid.Model/Reporting/BreakdownItem.cs
new file mode 100644
index 000000000..242750649
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/BreakdownItem.cs
@@ -0,0 +1,25 @@
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// One slice of a (e.g. a call type, a priority, a canonical state).
+ /// Breakdowns are capped to top-N with a synthetic "Other" bucket so payloads stay bounded.
+ ///
+ public class BreakdownItem
+ {
+ /// Resolved, human-readable label for the slice.
+ public string Label { get; set; }
+
+ /// The underlying id (type/priority/state id); null for the synthetic "Other" bucket.
+ public int? Id { get; set; }
+
+ public long Count { get; set; }
+
+ /// True for the synthetic aggregate "Other" bucket.
+ public bool IsOther { get; set; }
+
+ ///
+ /// Canonical availability of this slice; populated only for personnel/unit state breakdowns.
+ ///
+ public AvailabilityClass? Availability { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/DashboardReport.cs b/Core/Resgrid.Model/Reporting/DashboardReport.cs
new file mode 100644
index 000000000..3e49e4614
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/DashboardReport.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Composite, read-only dashboard payload returned in a single call. Contains scalar totals,
+ /// dense (zero-filled, UTC) time series, and bounded top-N+other breakdowns.
+ ///
+ /// is null for a SYSTEM-WIDE (cross-department) report, which is
+ /// produced only for the in-process BackOffice (Resgrid staff). Department-scoped HTTP callers
+ /// always receive their own department's data.
+ ///
+ /// Contains counts only (no PII), which is what makes cross-tenant aggregation safe.
+ ///
+ public class DashboardReport
+ {
+ /// The department this report is scoped to; null = system-wide (BackOffice only).
+ public int? DepartmentId { get; set; }
+
+ public DateTime StartUtc { get; set; }
+ public DateTime EndUtc { get; set; }
+ public ReportGranularity Granularity { get; set; }
+
+ /// When this report was generated (UTC).
+ public DateTime GeneratedUtc { get; set; }
+
+ ///
+ /// IANA/Windows timezone of the requesting context (from the caller's claim), provided so a
+ /// UI can label/shift the UTC buckets for display. Aggregation itself is always UTC.
+ ///
+ public string TimeZone { get; set; }
+
+ public ReportTotals Totals { get; set; } = new ReportTotals();
+
+ public List Series { get; set; } = new List();
+
+ public List Breakdowns { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ExportProfile.cs b/Core/Resgrid.Model/Reporting/ExportProfile.cs
new file mode 100644
index 000000000..759f9bec6
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ExportProfile.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Selects the field set / standardized mapping used when exporting incident or personnel data.
+ ///
+ public enum ExportProfile
+ {
+ /// Generic Resgrid field set (all available columns).
+ [Description("Generic")]
+ [Display(Name = "Generic")]
+ Generic = 0,
+
+ /// National Fire Incident Reporting System (NFIRS) field mapping (fire).
+ [Description("NFIRS")]
+ [Display(Name = "NFIRS")]
+ Nfirs = 1,
+
+ /// National EMS Information System (NEMSIS) field mapping (EMS).
+ [Description("NEMSIS")]
+ [Display(Name = "NEMSIS")]
+ Nemsis = 2
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/MetricPoint.cs b/Core/Resgrid.Model/Reporting/MetricPoint.cs
new file mode 100644
index 000000000..6a756e8e4
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/MetricPoint.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// A single point in a time-bucketed metric series. is the start of the
+ /// day or month (UTC). Series are dense: every bucket in the requested window is present, with
+ /// = 0 for buckets that had no data.
+ ///
+ public class MetricPoint
+ {
+ public DateTime BucketUtc { get; set; }
+
+ public long Value { get; set; }
+
+ public MetricPoint() { }
+
+ public MetricPoint(DateTime bucketUtc, long value)
+ {
+ BucketUtc = bucketUtc;
+ Value = value;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/MetricSeries.cs b/Core/Resgrid.Model/Reporting/MetricSeries.cs
new file mode 100644
index 000000000..7ec28cb28
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/MetricSeries.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// A named, time-bucketed series (e.g. "calls", "messages", "newUsers"). Points are dense
+ /// (zero-filled) and ordered ascending by bucket.
+ ///
+ public class MetricSeries
+ {
+ /// Stable machine key, e.g. "calls", "messages", "newUsers".
+ public string Key { get; set; }
+
+ public ReportGranularity Granularity { get; set; }
+
+ public List Points { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ParticipationReport.cs b/Core/Resgrid.Model/Reporting/ParticipationReport.cs
new file mode 100644
index 000000000..deb6f1faf
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ParticipationReport.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Personnel participation and compliance analytics: per-member call response rate, event/training
+ /// attendance, and certification expiry status. Especially relevant to volunteer departments.
+ ///
+ public class ParticipationReport
+ {
+ public int? DepartmentId { get; set; }
+ public DateTime StartUtc { get; set; }
+ public DateTime EndUtc { get; set; }
+ public DateTime GeneratedUtc { get; set; }
+
+ /// Number of calls in the window used as the denominator for response rates.
+ public long CallsInWindow { get; set; }
+
+ public List Members { get; set; } = new List();
+
+ /// Count of members with at least one expired certification.
+ public int MembersWithExpiredCertifications { get; set; }
+ }
+
+ public class MemberParticipation
+ {
+ public string UserId { get; set; }
+ public string Name { get; set; }
+
+ public long CallsResponded { get; set; }
+
+ /// CallsResponded / calls in window (0..1).
+ public double ResponseRate { get; set; }
+
+ public long EventsAttended { get; set; }
+ public long TrainingAttended { get; set; }
+
+ public int ExpiredCertifications { get; set; }
+ public int ExpiringCertifications { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ReportGranularity.cs b/Core/Resgrid.Model/Reporting/ReportGranularity.cs
new file mode 100644
index 000000000..9f6acd7ea
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ReportGranularity.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Time-bucketing granularity for reporting series. All buckets are anchored in UTC.
+ ///
+ public enum ReportGranularity
+ {
+ [Description("Day")]
+ [Display(Name = "Day")]
+ Day = 0,
+
+ [Description("Month")]
+ [Display(Name = "Month")]
+ Month = 1
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ReportTotals.cs b/Core/Resgrid.Model/Reporting/ReportTotals.cs
new file mode 100644
index 000000000..d3d721925
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ReportTotals.cs
@@ -0,0 +1,38 @@
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Scalar totals for a dashboard report. "InWindow" values are scoped to the requested
+ /// [startUtc, endUtc] range; "AllTime" values are scoped only by department (or system-wide).
+ /// Personnel/Unit availability counts reflect each resource's current (latest) status mapped to
+ /// a canonical via .
+ ///
+ public class ReportTotals
+ {
+ // Calls
+ public long CallsAllTime { get; set; }
+ public long CallsInWindow { get; set; }
+ public long ActiveCalls { get; set; }
+
+ // Messages
+ public long MessagesInWindow { get; set; }
+
+ // Personnel (current availability)
+ public int PersonnelTotal { get; set; }
+ public int PersonnelAvailable { get; set; }
+ public int PersonnelCommitted { get; set; }
+ public int PersonnelUnavailable { get; set; }
+
+ // Units (current availability)
+ public int UnitsTotal { get; set; }
+ public int UnitsAvailable { get; set; }
+ public int UnitsCommitted { get; set; }
+ public int UnitsUnavailable { get; set; }
+
+ // Users
+ public long NewUsersInWindow { get; set; }
+
+ // System-wide only (null for a department-scoped report)
+ public long? DepartmentsTotal { get; set; }
+ public long? NewDepartmentsInWindow { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ReportingAggregates.cs b/Core/Resgrid.Model/Reporting/ReportingAggregates.cs
new file mode 100644
index 000000000..f0f46b8ff
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ReportingAggregates.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace Resgrid.Model.Reporting
+{
+ // Lightweight projection types that aggregate SQL queries land into (Dapper). These are
+ // repository-facing only and are not part of the public dashboard contract. They deliberately
+ // carry ONLY aggregates (never materialized rows) so latency is independent of row/tenant count.
+
+ // Aliases use GroupKey/Bucket/Total (not Key/Count) to avoid reserved-word collisions across
+ // both SQL Server and PostgreSQL. Dapper maps result columns to these properties by name.
+
+ /// An integer-keyed grouped count (e.g. by priority, by call state, by raw status id).
+ public class CountByKeyResult
+ {
+ public int GroupKey { get; set; }
+ public long Total { get; set; }
+ }
+
+ /// A string-keyed grouped count (e.g. calls grouped by their Type string).
+ public class CountByStringKeyResult
+ {
+ public string GroupKey { get; set; }
+ public long Total { get; set; }
+ }
+
+ /// A time-bucketed grouped count. is the start of the day/month (UTC).
+ public class CountByDateBucketResult
+ {
+ public DateTime Bucket { get; set; }
+ public long Total { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ReportingDailyRollup.cs b/Core/Resgrid.Model/Reporting/ReportingDailyRollup.cs
new file mode 100644
index 000000000..47b437b83
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ReportingDailyRollup.cs
@@ -0,0 +1,44 @@
+using System;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// A pre-aggregated daily rollup row backing the heavy analytics (response times, UHU,
+ /// participation) so those reports stay fast regardless of source-table size. Written by the
+ /// rollup worker and read by the reporting service. A null is a
+ /// system-wide aggregate row. Maps to the ReportingDailyRollup table.
+ ///
+ public class ReportingDailyRollup
+ {
+ public long ReportingDailyRollupId { get; set; }
+
+ /// Department this rollup is for; null = system-wide.
+ public int? DepartmentId { get; set; }
+
+ /// The day this rollup covers (UTC, midnight).
+ public DateTime BucketDateUtc { get; set; }
+
+ /// What is measured, e.g. "TurnoutSeconds", "Uhu", "ResponseRate".
+ public string Metric { get; set; }
+
+ /// Optional sub-grouping key (e.g. call type, unit id, station id); null = overall.
+ public string Dimension { get; set; }
+
+ /// Number of items/events aggregated into this row.
+ public long ItemCount { get; set; }
+
+ /// Sum of the measured value (mean = SumValue / ItemCount).
+ public decimal? SumValue { get; set; }
+
+ public decimal? MinValue { get; set; }
+ public decimal? MaxValue { get; set; }
+
+ /// 50th percentile of the measured value.
+ public decimal? P50 { get; set; }
+
+ /// 90th percentile of the measured value (the NFPA reporting standard).
+ public decimal? P90 { get; set; }
+
+ public DateTime CreatedOnUtc { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ReportingMetrics.cs b/Core/Resgrid.Model/Reporting/ReportingMetrics.cs
new file mode 100644
index 000000000..01b9bf71d
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ReportingMetrics.cs
@@ -0,0 +1,30 @@
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Canonical names shared by the rollup worker (writer)
+ /// and the reporting service (reader). Durations are stored in seconds; rates are 0..1.
+ ///
+ public static class ReportingMetrics
+ {
+ /// Count of calls in the bucket (uses ItemCount only).
+ public const string CallCount = "CallCount";
+
+ /// Alarm handling / call processing time: LoggedOn -> DispatchOn (NFPA 1221), seconds.
+ public const string CallProcessingSeconds = "CallProcessingSeconds";
+
+ /// Turnout time: dispatch -> first unit enroute/responding, seconds.
+ public const string TurnoutSeconds = "TurnoutSeconds";
+
+ /// Travel time: enroute -> first on-scene, seconds.
+ public const string TravelSeconds = "TravelSeconds";
+
+ /// Total response time: LoggedOn -> first on-scene (NFPA 1710/1720), seconds.
+ public const string TotalResponseSeconds = "TotalResponseSeconds";
+
+ /// Unit Hour Utilization (committed time / in-service time), 0..1.
+ public const string UnitHourUtilization = "Uhu";
+
+ /// Member call response rate (calls responded / calls in period), 0..1.
+ public const string MemberResponseRate = "ResponseRate";
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ResponseTimeReport.cs b/Core/Resgrid.Model/Reporting/ResponseTimeReport.cs
new file mode 100644
index 000000000..765ba7e1e
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/ResponseTimeReport.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Response-time analytics for the call lifecycle, reported as 90th-percentile (the NFPA standard)
+ /// plus mean, with compliance against configurable NFPA thresholds
+ /// (1710 career / 1720 volunteer travel, 1221 dispatch/alarm handling).
+ ///
+ public class ResponseTimeReport
+ {
+ public int? DepartmentId { get; set; }
+ public DateTime StartUtc { get; set; }
+ public DateTime EndUtc { get; set; }
+ public DateTime GeneratedUtc { get; set; }
+
+ /// One entry per lifecycle phase (alarm handling, turnout, travel, total response).
+ public List Metrics { get; set; } = new List();
+
+ /// Optional groupings of the total-response metric (e.g. by call type / station).
+ public List Breakdowns { get; set; } = new List();
+ }
+
+ public class ResponseTimeMetric
+ {
+ /// e.g. "alarmHandling", "turnout", "travel", "totalResponse".
+ public string Key { get; set; }
+
+ public double MeanSeconds { get; set; }
+ public double P50Seconds { get; set; }
+ public double P90Seconds { get; set; }
+ public long SampleCount { get; set; }
+
+ /// Configured NFPA threshold for this phase, if any.
+ public double? ThresholdSeconds { get; set; }
+
+ /// Percent of samples within , if a threshold is set.
+ public double? CompliancePercent { get; set; }
+ }
+
+ public class ResponseTimeBreakdown
+ {
+ /// e.g. "byCallType", "byStation".
+ public string Key { get; set; }
+
+ public List Items { get; set; } = new List();
+ }
+
+ public class ResponseTimeBreakdownItem
+ {
+ public string Label { get; set; }
+ public int? Id { get; set; }
+ public double P90Seconds { get; set; }
+ public long SampleCount { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/UtilizationReport.cs b/Core/Resgrid.Model/Reporting/UtilizationReport.cs
new file mode 100644
index 000000000..99d8a826a
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/UtilizationReport.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Reporting
+{
+ ///
+ /// Unit Hour Utilization (UHU) and workload analytics: how much of each unit's in-service time was
+ /// spent committed vs. available, plus busiest hours and resource-exhaustion windows.
+ ///
+ public class UtilizationReport
+ {
+ public int? DepartmentId { get; set; }
+ public DateTime StartUtc { get; set; }
+ public DateTime EndUtc { get; set; }
+ public DateTime GeneratedUtc { get; set; }
+
+ /// Committed-hours / total-hours across all units in the window (0..1).
+ public double AggregateUhu { get; set; }
+
+ /// Count of distinct intervals in the window where zero units were available.
+ public long ZeroUnitsAvailableEvents { get; set; }
+
+ /// Peak number of simultaneously active calls observed in the window.
+ public int PeakConcurrentCalls { get; set; }
+
+ public List Units { get; set; } = new List();
+
+ /// Call/workload distribution by hour-of-day (0..23, UTC), dense.
+ public List WorkloadByHour { get; set; } = new List();
+ }
+
+ public class UnitUtilization
+ {
+ public int UnitId { get; set; }
+ public string UnitName { get; set; }
+ public double BusySeconds { get; set; }
+ public double InServiceSeconds { get; set; }
+
+ /// BusySeconds / InServiceSeconds (0..1).
+ public double Uhu { get; set; }
+
+ public long CallCount { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IReportingRepository.cs b/Core/Resgrid.Model/Repositories/IReportingRepository.cs
new file mode 100644
index 000000000..893a6f4f1
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IReportingRepository.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Reporting;
+
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Set-based aggregate data access for platform reporting. Every method returns ONLY aggregates
+ /// (counts/buckets), produced by GROUP BY/COUNT over indexed columns — no row materialization and
+ /// no per-department iteration. A null means system-wide
+ /// (cross-department), used only by the in-process BackOffice.
+ ///
+ public interface IReportingRepository
+ {
+ // ----- Scalar totals -----
+
+ /// Count of non-deleted calls. When both dates are null, returns the all-time total for the scope.
+ Task GetCallsCountAsync(int? departmentId, DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken = default);
+
+ /// Count of currently open/active calls for the scope.
+ Task GetActiveCallsCountAsync(int? departmentId, CancellationToken cancellationToken = default);
+
+ Task GetMessagesCountAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default);
+
+ Task GetNewUsersCountAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default);
+
+ /// Count of active members for the scope (excludes deleted/disabled).
+ Task GetPersonnelCountAsync(int? departmentId, CancellationToken cancellationToken = default);
+
+ Task GetUnitsCountAsync(int? departmentId, CancellationToken cancellationToken = default);
+
+ /// System-wide only: non-deleted department count (optionally new-in-window).
+ Task GetDepartmentsCountAsync(DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken = default);
+
+ // ----- Time-bucketed series (sparse; the service zero-fills) -----
+
+ Task> GetCallsByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default);
+
+ Task> GetMessagesByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default);
+
+ Task> GetNewUsersByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default);
+
+ // ----- Breakdowns (capped to top-N + "other" in the service) -----
+
+ Task> GetCallsBreakdownByTypeAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default);
+
+ Task> GetCallsBreakdownByPriorityAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default);
+
+ Task> GetCallsBreakdownByStateAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default);
+
+ // ----- Latest-state counts (grouped by the RAW status id over the latest-per-entity set) -----
+ // The service resolves each raw id to a canonical base type / AvailabilityClass.
+
+ Task> GetLatestPersonnelStateCountsAsync(int? departmentId, CancellationToken cancellationToken = default);
+
+ Task> GetLatestUnitStateCountsAsync(int? departmentId, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IReportingRollupRepository.cs b/Core/Resgrid.Model/Repositories/IReportingRollupRepository.cs
new file mode 100644
index 000000000..2bd0833e1
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IReportingRollupRepository.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Reporting;
+
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Read/write access to the pre-aggregated store that backs the
+ /// heavy analytics. The rollup worker writes daily rows; the reporting service reads them. A null
+ /// departmentId targets the system-wide (cross-department) rollup rows.
+ ///
+ public interface IReportingRollupRepository
+ {
+ ///
+ /// Replaces all rollup rows for a (department, day) with the supplied set — delete-then-insert,
+ /// so recomputing a day is idempotent. Returns the number of rows written.
+ ///
+ Task UpsertDailyRollupAsync(int? departmentId, DateTime bucketDateUtc,
+ IEnumerable rows, CancellationToken cancellationToken = default);
+
+ /// Reads rollup rows for a metric within the day range for the given scope.
+ Task> GetRollupsAsync(int? departmentId, DateTime startUtc,
+ DateTime endUtc, string metric, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IPlatformReportingService.cs b/Core/Resgrid.Model/Services/IPlatformReportingService.cs
new file mode 100644
index 000000000..679df5ff5
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IPlatformReportingService.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Reporting;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Platform reporting and analytics. Powers dashboards, widgets and the Dispatch app's realtime
+ /// availability display, both at a per-department level and (for the in-process BackOffice only) a
+ /// cross-department system level.
+ ///
+ /// SECURITY: a null departmentId means SYSTEM-WIDE aggregation across every department and
+ /// is intended ONLY for the Resgrid-staff BackOffice running Core in-process. The department-locked
+ /// v4 HTTP controller MUST always pass the caller's claim DepartmentId and never accept a
+ /// client-supplied value, to prevent cross-department data leakage. All results are counts/
+ /// aggregates only (no PII).
+ ///
+ public interface IPlatformReportingService
+ {
+ ///
+ /// Composite dashboard payload (scalar totals, dense UTC time series, top-N+other breakdowns)
+ /// for the window [, ].
+ ///
+ /// Department to scope to; null = system-wide (BackOffice only).
+ Task GetDashboardReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ ReportGranularity granularity, int topN = 5, bool bypassCache = false, CancellationToken cancellationToken = default);
+
+ /// Response-time / NFPA analytics (alarm handling, turnout, travel, total response).
+ Task GetResponseTimeReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default);
+
+ /// Unit Hour Utilization and workload analytics.
+ Task GetUtilizationReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default);
+
+ /// Personnel participation and certification-compliance analytics.
+ Task GetParticipationReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Streams a CSV export of incidents for the window using the requested field mapping
+ /// (). NFIRS/NEMSIS profiles emit standardized incident fields.
+ ///
+ Task ExportIncidentsCsvAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ ExportProfile profile, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the standardized required fields for a profile that Resgrid does not currently capture
+ /// (the export "gap report"). Empty for .
+ ///
+ IReadOnlyList GetUnmappedRequiredExportFields(ExportProfile profile);
+
+ ///
+ /// Classifies a person's current status value into a canonical .
+ /// is the latest ActionLog.ActionTypeId — either a built-in
+ /// ActionTypes value or a CustomStateDetailId. Reusable by the Dispatch app's realtime
+ /// availability display so the "available for a call?" answer is consistent everywhere.
+ ///
+ Task ClassifyPersonnelAvailabilityAsync(int departmentId, int actionTypeId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Classifies a unit's current status value into a canonical .
+ /// is the latest UnitState.State — either a built-in
+ /// UnitStateTypes value or a CustomStateDetailId.
+ ///
+ Task ClassifyUnitAvailabilityAsync(int departmentId, int unitState, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IReportingRollupProcessor.cs b/Core/Resgrid.Model/Services/IReportingRollupProcessor.cs
new file mode 100644
index 000000000..e5770bb1a
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IReportingRollupProcessor.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Computes the pre-aggregated daily reporting rollups (response times, volume, utilization,
+ /// participation) and writes them to the rollup store. Run as a nightly background job and via the
+ /// admin backfill command. Because it is a batch job (not the latency-bounded request path) it may
+ /// iterate departments and materialize a day's rows to compute percentiles.
+ ///
+ public interface IReportingRollupProcessor
+ {
+ /// Computes and upserts the rollup rows for one department for one UTC day.
+ Task RunDailyRollupForDepartmentAsync(int departmentId, DateTime dayUtc, CancellationToken cancellationToken = default);
+
+ ///
+ /// Computes per-department rollups for every supplied department for one UTC day, plus a
+ /// system-wide (DepartmentId null) aggregate row from the combined samples.
+ ///
+ Task RunDailyRollupForAllAsync(DateTime dayUtc, IEnumerable departmentIds, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs
index 4a4c34e35..66eae0886 100644
--- a/Core/Resgrid.Services/AuditService.cs
+++ b/Core/Resgrid.Services/AuditService.cs
@@ -39,7 +39,11 @@ public async Task> GetAllAuditLogsForDepartmentAsync(int departme
public async Task> GetAuditLogsForDepartmentPagedAsync(int departmentId, DateTime startDate, DateTime endDate, AuditLogTypes? logType, int page, int pageSize)
{
- var logs = await _auditLogsRepository.GetAuditLogsForDepartmentPagedAsync(departmentId, startDate, endDate, (int?)logType, page, pageSize);
+ // Clamp paging so the repository never builds a negative OFFSET or an invalid LIMIT/FETCH.
+ var safePage = page < 1 ? 1 : page;
+ var safePageSize = pageSize < 1 ? 1 : (pageSize > 1000 ? 1000 : pageSize);
+
+ var logs = await _auditLogsRepository.GetAuditLogsForDepartmentPagedAsync(departmentId, startDate, endDate, (int?)logType, safePage, safePageSize);
return logs.ToList();
}
diff --git a/Core/Resgrid.Services/PlatformReportingService.cs b/Core/Resgrid.Services/PlatformReportingService.cs
new file mode 100644
index 000000000..0125dd3eb
--- /dev/null
+++ b/Core/Resgrid.Services/PlatformReportingService.cs
@@ -0,0 +1,464 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Reporting;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Platform reporting/analytics service. All aggregation is delegated to set-based SQL in
+ /// ; this service only composes results, zero-fills series, caps
+ /// breakdowns, and applies caching. A null departmentId means SYSTEM-WIDE (BackOffice/in-process
+ /// only) — see for the isolation contract.
+ ///
+ public class PlatformReportingService : IPlatformReportingService
+ {
+ private static readonly string DashboardCacheKey = "PlatformReport_Dashboard_{0}";
+ private static readonly string AnalyticsCacheKey = "PlatformReport_Analytics_{0}";
+ private static readonly TimeSpan CacheLength = TimeSpan.FromMinutes(10);
+
+ // NFPA 90th-percentile targets (seconds) used as default compliance thresholds.
+ private const double ThresholdCallProcessingSeconds = 90; // NFPA 1221 alarm handling
+ private const double ThresholdTurnoutSeconds = 80; // NFPA 1710 turnout
+ private const double ThresholdTravelSeconds = 240; // NFPA 1710 travel
+ private const double ThresholdTotalResponseSeconds = 360; // total response
+
+ private readonly IReportingRepository _reportingRepository;
+ private readonly IReportingRollupRepository _rollupRepository;
+ private readonly ICacheProvider _cacheProvider;
+ private readonly ICustomStateService _customStateService;
+ private readonly ICallsService _callsService;
+
+ public PlatformReportingService(IReportingRepository reportingRepository, IReportingRollupRepository rollupRepository,
+ ICacheProvider cacheProvider, ICustomStateService customStateService, ICallsService callsService)
+ {
+ _reportingRepository = reportingRepository;
+ _rollupRepository = rollupRepository;
+ _cacheProvider = cacheProvider;
+ _customStateService = customStateService;
+ _callsService = callsService;
+ }
+
+ public async Task GetDashboardReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ ReportGranularity granularity, int topN = 5, bool bypassCache = false, CancellationToken cancellationToken = default)
+ {
+ if (topN <= 0)
+ topN = 5;
+
+ async Task buildReport()
+ {
+ var report = new DashboardReport
+ {
+ DepartmentId = departmentId,
+ StartUtc = startUtc,
+ EndUtc = endUtc,
+ Granularity = granularity,
+ GeneratedUtc = DateTime.UtcNow
+ };
+
+ // Scalar totals (each is a single set-based aggregate).
+ report.Totals.CallsAllTime = await _reportingRepository.GetCallsCountAsync(departmentId, null, null, cancellationToken);
+ report.Totals.CallsInWindow = await _reportingRepository.GetCallsCountAsync(departmentId, startUtc, endUtc, cancellationToken);
+ report.Totals.ActiveCalls = await _reportingRepository.GetActiveCallsCountAsync(departmentId, cancellationToken);
+ report.Totals.PersonnelTotal = (int)await _reportingRepository.GetPersonnelCountAsync(departmentId, cancellationToken);
+ report.Totals.UnitsTotal = (int)await _reportingRepository.GetUnitsCountAsync(departmentId, cancellationToken);
+ report.Totals.MessagesInWindow = await _reportingRepository.GetMessagesCountAsync(departmentId, startUtc, endUtc, cancellationToken);
+ // "New users" has no source creation-date column in the schema, so it stays 0 (see repo).
+ report.Totals.NewUsersInWindow = await _reportingRepository.GetNewUsersCountAsync(departmentId, startUtc, endUtc, cancellationToken);
+
+ // System-wide reports (BackOffice) also carry platform department totals.
+ if (!departmentId.HasValue)
+ {
+ report.Totals.DepartmentsTotal = await _reportingRepository.GetDepartmentsCountAsync(null, null, cancellationToken);
+ report.Totals.NewDepartmentsInWindow = await _reportingRepository.GetDepartmentsCountAsync(startUtc, endUtc, cancellationToken);
+ }
+
+ // Dense, zero-filled series.
+ var callsSparse = await _reportingRepository.GetCallsByDateBucketAsync(departmentId, startUtc, endUtc, granularity, cancellationToken);
+ report.Series.Add(BuildDenseSeries("calls", callsSparse, startUtc, endUtc, granularity));
+
+ var messagesSparse = await _reportingRepository.GetMessagesByDateBucketAsync(departmentId, startUtc, endUtc, granularity, cancellationToken);
+ report.Series.Add(BuildDenseSeries("messages", messagesSparse, startUtc, endUtc, granularity));
+
+ // Bounded breakdowns (top-N + "Other").
+ var byType = await _reportingRepository.GetCallsBreakdownByTypeAsync(departmentId, startUtc, endUtc, cancellationToken);
+ report.Breakdowns.Add(BuildStringBreakdown("callsByType", byType, topN));
+
+ var byPriority = await _reportingRepository.GetCallsBreakdownByPriorityAsync(departmentId, startUtc, endUtc, cancellationToken);
+ report.Breakdowns.Add(BuildIntBreakdown("callsByPriority", byPriority, topN));
+
+ var byState = await _reportingRepository.GetCallsBreakdownByStateAsync(departmentId, startUtc, endUtc, cancellationToken);
+ report.Breakdowns.Add(BuildIntBreakdown("callsByStatus", byState, topN));
+
+ // Realtime availability: latest-state-per-entity counts resolved to canonical availability.
+ // Custom statuses are resolved via the department's CustomStateDetail.BaseType map; for
+ // system-wide (null department) only built-in statuses are resolved (custom -> Unknown).
+ var personnelMap = departmentId.HasValue
+ ? await GetCustomDetailMapAsync(departmentId.Value, CustomStateTypes.Personnel)
+ : null;
+ var unitMap = departmentId.HasValue
+ ? await GetCustomDetailMapAsync(departmentId.Value, CustomStateTypes.Unit)
+ : null;
+
+ var personnelStates = await _reportingRepository.GetLatestPersonnelStateCountsAsync(departmentId, cancellationToken);
+ report.Breakdowns.Add(BuildAvailabilityBreakdown("personnelByState", personnelStates, personnelMap, isPersonnel: true, report.Totals));
+
+ var unitStates = await _reportingRepository.GetLatestUnitStateCountsAsync(departmentId, cancellationToken);
+ report.Breakdowns.Add(BuildAvailabilityBreakdown("unitsByStatus", unitStates, unitMap, isPersonnel: false, report.Totals));
+
+ return report;
+ }
+
+ var cacheKey = string.Format(DashboardCacheKey,
+ $"{departmentId?.ToString() ?? "ALL"}_{startUtc.Ticks}_{endUtc.Ticks}_{(int)granularity}_{topN}");
+
+ if (!bypassCache && Resgrid.Config.SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(cacheKey, buildReport, CacheLength);
+
+ return await buildReport();
+ }
+
+ public async Task ClassifyPersonnelAvailabilityAsync(int departmentId, int actionTypeId, CancellationToken cancellationToken = default)
+ {
+ var map = await GetCustomDetailMapAsync(departmentId, CustomStateTypes.Personnel);
+ return ResolvePersonnel(actionTypeId, map);
+ }
+
+ public async Task ClassifyUnitAvailabilityAsync(int departmentId, int unitState, CancellationToken cancellationToken = default)
+ {
+ var map = await GetCustomDetailMapAsync(departmentId, CustomStateTypes.Unit);
+ return ResolveUnit(unitState, map);
+ }
+
+ public async Task GetResponseTimeReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default)
+ {
+ async Task build()
+ {
+ var report = new ResponseTimeReport
+ {
+ DepartmentId = departmentId,
+ StartUtc = startUtc,
+ EndUtc = endUtc,
+ GeneratedUtc = DateTime.UtcNow
+ };
+
+ var defs = new (string Metric, double Threshold)[]
+ {
+ (ReportingMetrics.CallProcessingSeconds, ThresholdCallProcessingSeconds),
+ (ReportingMetrics.TurnoutSeconds, ThresholdTurnoutSeconds),
+ (ReportingMetrics.TravelSeconds, ThresholdTravelSeconds),
+ (ReportingMetrics.TotalResponseSeconds, ThresholdTotalResponseSeconds),
+ };
+
+ foreach (var def in defs)
+ {
+ var rollups = (await _rollupRepository.GetRollupsAsync(departmentId, startUtc, endUtc, def.Metric, cancellationToken))
+ .Where(r => r.ItemCount > 0).ToList();
+ if (rollups.Count == 0)
+ continue;
+
+ var samples = rollups.Sum(r => r.ItemCount);
+ var sum = rollups.Sum(r => (double)(r.SumValue ?? 0));
+
+ report.Metrics.Add(new ResponseTimeMetric
+ {
+ Key = def.Metric,
+ SampleCount = samples,
+ MeanSeconds = samples > 0 ? sum / samples : 0,
+ // Cross-day percentiles are approximated as sample-weighted averages of the daily
+ // percentiles (the per-sample distribution is not retained across days).
+ P50Seconds = WeightedAverage(rollups, r => (double)(r.P50 ?? 0)),
+ P90Seconds = WeightedAverage(rollups, r => (double)(r.P90 ?? 0)),
+ ThresholdSeconds = def.Threshold
+ // CompliancePercent is intentionally left null: it needs per-sample data the
+ // daily aggregate does not retain. Added when the rollup stores within-threshold counts.
+ });
+ }
+
+ return report;
+ }
+
+ return await CachedAsync($"resp_{Scope(departmentId)}_{startUtc.Ticks}_{endUtc.Ticks}", build, bypassCache);
+ }
+
+ public async Task GetUtilizationReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default)
+ {
+ async Task build()
+ {
+ var report = new UtilizationReport
+ {
+ DepartmentId = departmentId,
+ StartUtc = startUtc,
+ EndUtc = endUtc,
+ GeneratedUtc = DateTime.UtcNow
+ };
+
+ var uhu = (await _rollupRepository.GetRollupsAsync(departmentId, startUtc, endUtc, ReportingMetrics.UnitHourUtilization, cancellationToken))
+ .Where(r => r.ItemCount > 0).ToList();
+ if (uhu.Count > 0)
+ report.AggregateUhu = WeightedAverage(uhu, r => (double)(r.SumValue ?? 0) / r.ItemCount);
+
+ // Per-unit detail and concurrency/exhaustion metrics are produced once the rollup worker
+ // computes them from unit-state durations (see ReportingRollupProcessor extension point).
+ return report;
+ }
+
+ return await CachedAsync($"util_{Scope(departmentId)}_{startUtc.Ticks}_{endUtc.Ticks}", build, bypassCache);
+ }
+
+ public async Task GetParticipationReportAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ bool bypassCache = false, CancellationToken cancellationToken = default)
+ {
+ async Task build()
+ {
+ var report = new ParticipationReport
+ {
+ DepartmentId = departmentId,
+ StartUtc = startUtc,
+ EndUtc = endUtc,
+ GeneratedUtc = DateTime.UtcNow
+ };
+
+ var calls = (await _rollupRepository.GetRollupsAsync(departmentId, startUtc, endUtc, ReportingMetrics.CallCount, cancellationToken)).ToList();
+ report.CallsInWindow = calls.Sum(r => r.ItemCount);
+
+ // Per-member response/attendance/certification detail is produced once the rollup worker
+ // computes it (see ReportingRollupProcessor extension point).
+ return report;
+ }
+
+ return await CachedAsync($"part_{Scope(departmentId)}_{startUtc.Ticks}_{endUtc.Ticks}", build, bypassCache);
+ }
+
+ public async Task ExportIncidentsCsvAsync(int? departmentId, DateTime startUtc, DateTime endUtc,
+ ExportProfile profile, CancellationToken cancellationToken = default)
+ {
+ // Incident export is department-scoped: it emits PII-bearing incident rows, so a system-wide
+ // (null) export is intentionally not supported.
+ if (!departmentId.HasValue)
+ throw new NotSupportedException("Incident export is department-scoped; a departmentId is required.");
+
+ var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId.Value, startUtc, endUtc);
+ var bytes = Reporting.IncidentExport.BuildCsv(profile, calls);
+ return new MemoryStream(bytes, writable: false);
+ }
+
+ public IReadOnlyList GetUnmappedRequiredExportFields(ExportProfile profile)
+ => Reporting.IncidentExport.GetUnmappedRequiredFields(profile);
+
+ #region Helpers
+
+ // Generates a dense, ascending, zero-filled series in C# (portable across SQL dialects).
+ internal static MetricSeries BuildDenseSeries(string key, IEnumerable sparse,
+ DateTime startUtc, DateTime endUtc, ReportGranularity granularity)
+ {
+ var map = new Dictionary();
+ foreach (var row in sparse)
+ {
+ var bucket = NormalizeBucket(row.Bucket, granularity);
+ map[bucket] = row.Total;
+ }
+
+ var series = new MetricSeries { Key = key, Granularity = granularity };
+
+ var cursor = granularity == ReportGranularity.Month
+ ? new DateTime(startUtc.Year, startUtc.Month, 1)
+ : startUtc.Date;
+
+ while (cursor <= endUtc)
+ {
+ var value = map.TryGetValue(cursor, out var v) ? v : 0L;
+ series.Points.Add(new MetricPoint(cursor, value));
+ cursor = granularity == ReportGranularity.Month ? cursor.AddMonths(1) : cursor.AddDays(1);
+ }
+
+ return series;
+ }
+
+ private static DateTime NormalizeBucket(DateTime bucket, ReportGranularity granularity)
+ => granularity == ReportGranularity.Month ? new DateTime(bucket.Year, bucket.Month, 1) : bucket.Date;
+
+ private static Breakdown BuildStringBreakdown(string key, IEnumerable rows, int topN)
+ {
+ var ordered = rows.OrderByDescending(r => r.Total).ToList();
+ var breakdown = new Breakdown { Key = key };
+
+ foreach (var row in ordered.Take(topN))
+ {
+ breakdown.Items.Add(new BreakdownItem
+ {
+ Label = string.IsNullOrWhiteSpace(row.GroupKey) ? "(none)" : row.GroupKey,
+ Id = null,
+ Count = row.Total
+ });
+ }
+
+ AppendOther(breakdown, ordered.Skip(topN));
+ return breakdown;
+ }
+
+ private static Breakdown BuildIntBreakdown(string key, IEnumerable rows, int topN)
+ {
+ var ordered = rows.OrderByDescending(r => r.Total).ToList();
+ var breakdown = new Breakdown { Key = key };
+
+ foreach (var row in ordered.Take(topN))
+ {
+ breakdown.Items.Add(new BreakdownItem
+ {
+ Label = row.GroupKey.ToString(),
+ Id = row.GroupKey,
+ Count = row.Total
+ });
+ }
+
+ AppendOtherFromInt(breakdown, ordered.Skip(topN));
+ return breakdown;
+ }
+
+ private static void AppendOther(Breakdown breakdown, IEnumerable rest)
+ {
+ var otherTotal = rest.Sum(r => r.Total);
+ if (otherTotal > 0)
+ breakdown.Items.Add(new BreakdownItem { Label = "Other", Id = null, Count = otherTotal, IsOther = true });
+ }
+
+ private static void AppendOtherFromInt(Breakdown breakdown, IEnumerable rest)
+ {
+ var otherTotal = rest.Sum(r => r.Total);
+ if (otherTotal > 0)
+ breakdown.Items.Add(new BreakdownItem { Label = "Other", Id = null, Count = otherTotal, IsOther = true });
+ }
+
+ // Builds a personnel/unit "by canonical state" breakdown and tallies the availability sub-totals.
+ // Not top-N capped: the number of distinct statuses is naturally bounded (a few dozen at most).
+ private static Breakdown BuildAvailabilityBreakdown(string key, IEnumerable rows,
+ IReadOnlyDictionary customMap, bool isPersonnel, ReportTotals totals)
+ {
+ var breakdown = new Breakdown { Key = key };
+
+ foreach (var row in rows.OrderByDescending(r => r.Total))
+ {
+ var availability = isPersonnel
+ ? ResolvePersonnel(row.GroupKey, customMap)
+ : ResolveUnit(row.GroupKey, customMap);
+
+ breakdown.Items.Add(new BreakdownItem
+ {
+ Label = isPersonnel ? PersonnelLabel(row.GroupKey, customMap) : UnitLabel(row.GroupKey, customMap),
+ Id = row.GroupKey,
+ Count = row.Total,
+ Availability = availability
+ });
+
+ Tally(totals, isPersonnel, availability, row.Total);
+ }
+
+ return breakdown;
+ }
+
+ private static void Tally(ReportTotals totals, bool isPersonnel, AvailabilityClass availability, long count)
+ {
+ var c = (int)count;
+ if (isPersonnel)
+ {
+ switch (availability)
+ {
+ case AvailabilityClass.Available: totals.PersonnelAvailable += c; break;
+ case AvailabilityClass.Committed: totals.PersonnelCommitted += c; break;
+ case AvailabilityClass.Unavailable: totals.PersonnelUnavailable += c; break;
+ }
+ }
+ else
+ {
+ switch (availability)
+ {
+ // A delayed unit is still available to respond, just slower.
+ case AvailabilityClass.Available:
+ case AvailabilityClass.Delayed: totals.UnitsAvailable += c; break;
+ case AvailabilityClass.Committed: totals.UnitsCommitted += c; break;
+ case AvailabilityClass.Unavailable: totals.UnitsUnavailable += c; break;
+ }
+ }
+ }
+
+ // raw status id -> canonical availability. Custom statuses (looked up in the department's
+ // CustomStateDetail map) resolve via BaseType; otherwise the value is a built-in enum.
+ private static AvailabilityClass ResolvePersonnel(int rawKey, IReadOnlyDictionary customMap)
+ {
+ if (customMap != null && customMap.TryGetValue(rawKey, out var detail))
+ return AvailabilityMatrix.ForCustomBaseType(detail.BaseType);
+ return AvailabilityMatrix.ForBuiltInPersonnelActionType(rawKey);
+ }
+
+ private static AvailabilityClass ResolveUnit(int rawKey, IReadOnlyDictionary customMap)
+ {
+ if (customMap != null && customMap.TryGetValue(rawKey, out var detail))
+ return AvailabilityMatrix.ForCustomBaseType(detail.BaseType);
+ return AvailabilityMatrix.ForUnitStateType(rawKey);
+ }
+
+ private static string PersonnelLabel(int rawKey, IReadOnlyDictionary customMap)
+ {
+ if (customMap != null && customMap.TryGetValue(rawKey, out var detail))
+ return string.IsNullOrWhiteSpace(detail.ButtonText) ? rawKey.ToString() : detail.ButtonText;
+ return Enum.IsDefined(typeof(ActionTypes), rawKey) ? ((ActionTypes)rawKey).ToString() : rawKey.ToString();
+ }
+
+ private static string UnitLabel(int rawKey, IReadOnlyDictionary customMap)
+ {
+ if (customMap != null && customMap.TryGetValue(rawKey, out var detail))
+ return string.IsNullOrWhiteSpace(detail.ButtonText) ? rawKey.ToString() : detail.ButtonText;
+ return Enum.IsDefined(typeof(UnitStateTypes), rawKey) ? ((UnitStateTypes)rawKey).ToString() : rawKey.ToString();
+ }
+
+ private static string Scope(int? departmentId) => departmentId?.ToString() ?? "ALL";
+
+ // Sample-weighted (by ItemCount) average across daily rollup rows.
+ private static double WeightedAverage(IReadOnlyCollection rows, Func selector)
+ {
+ long totalWeight = rows.Sum(r => r.ItemCount);
+ if (totalWeight <= 0)
+ return 0d;
+ var acc = rows.Sum(r => selector(r) * r.ItemCount);
+ return acc / totalWeight;
+ }
+
+ private async Task CachedAsync(string keySuffix, Func> build, bool bypassCache) where T : class
+ {
+ var cacheKey = string.Format(AnalyticsCacheKey, keySuffix);
+ if (!bypassCache && Resgrid.Config.SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(cacheKey, build, CacheLength);
+ return await build();
+ }
+
+ // Loads the active CustomStateDetail map (CustomStateDetailId -> detail) for a department, filtered
+ // to a custom-state type (Personnel or Unit). Used to resolve custom statuses to their base type.
+ private async Task> GetCustomDetailMapAsync(int departmentId, CustomStateTypes type)
+ {
+ var map = new Dictionary();
+ var states = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId);
+ if (states == null)
+ return map;
+
+ foreach (var state in states.Where(s => s.Type == (int)type))
+ {
+ foreach (var detail in state.GetActiveDetails())
+ map[detail.CustomStateDetailId] = detail;
+ }
+
+ return map;
+ }
+
+ #endregion
+ }
+}
diff --git a/Core/Resgrid.Services/Reporting/IncidentExport.cs b/Core/Resgrid.Services/Reporting/IncidentExport.cs
new file mode 100644
index 000000000..661b7da36
--- /dev/null
+++ b/Core/Resgrid.Services/Reporting/IncidentExport.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using Resgrid.Model;
+using Resgrid.Model.Reporting;
+
+namespace Resgrid.Services.Reporting
+{
+ ///
+ /// Incident CSV export and the NFIRS/NEMSIS field-mapping "readiness" layer. Each profile declares
+ /// its standardized field set; fields Resgrid captures are mapped, fields it does not are emitted as
+ /// empty cells (a "gap") and reported via . The output header
+ /// is always the full profile schema so the file is structurally submission-ready.
+ ///
+ public static class IncidentExport
+ {
+ private sealed class ExportField
+ {
+ public string Name { get; }
+ public Func Map { get; } // null => not captured by Resgrid (a gap)
+ public bool Required { get; }
+
+ public ExportField(string name, Func map, bool required = false)
+ {
+ Name = name;
+ Map = map;
+ Required = required;
+ }
+
+ public bool IsGap => Map == null;
+ }
+
+ public static byte[] BuildCsv(ExportProfile profile, IEnumerable calls)
+ {
+ var fields = GetFields(profile);
+ var sb = new StringBuilder();
+
+ sb.AppendLine(string.Join(",", fields.Select(f => Escape(f.Name))));
+
+ foreach (var call in calls ?? Enumerable.Empty())
+ sb.AppendLine(string.Join(",", fields.Select(f => Escape(f.Map == null ? string.Empty : (f.Map(call) ?? string.Empty)))));
+
+ return new UTF8Encoding(false).GetBytes(sb.ToString());
+ }
+
+ public static IReadOnlyList GetUnmappedRequiredFields(ExportProfile profile)
+ => GetFields(profile).Where(f => f.Required && f.IsGap).Select(f => f.Name).ToList();
+
+ private static IReadOnlyList GetFields(ExportProfile profile)
+ {
+ switch (profile)
+ {
+ case ExportProfile.Nfirs: return Nfirs;
+ case ExportProfile.Nemsis: return Nemsis;
+ default: return Generic;
+ }
+ }
+
+ // ----- Generic Resgrid incident columns (no required/standardized gaps) -----
+ private static readonly IReadOnlyList Generic = new List
+ {
+ new ExportField("CallId", c => Int(c.CallId)),
+ new ExportField("Number", c => c.Number),
+ new ExportField("IncidentNumber", c => c.IncidentNumber),
+ new ExportField("Type", c => c.Type),
+ new ExportField("Priority", c => Int(c.Priority)),
+ new ExportField("State", c => Int(c.State)),
+ new ExportField("Name", c => c.Name),
+ new ExportField("NatureOfCall", c => c.NatureOfCall),
+ new ExportField("Address", c => c.Address),
+ new ExportField("LoggedOnUtc", c => Utc(c.LoggedOn)),
+ new ExportField("DispatchOnUtc", c => Utc(c.DispatchOn)),
+ new ExportField("ClosedOnUtc", c => Utc(c.ClosedOn)),
+ new ExportField("CallSource", c => Int(c.CallSource)),
+ new ExportField("DispatchCount", c => Int(c.DispatchCount)),
+ };
+
+ // ----- NFIRS 5.0 (Basic module) representative field set (fire) -----
+ private static readonly IReadOnlyList Nfirs = new List
+ {
+ new ExportField("FDID", null, required: true), // department's NFIRS Fire Dept ID — not captured
+ new ExportField("IncidentDate", c => Utc(c.LoggedOn), required: true),
+ new ExportField("Station", null),
+ new ExportField("IncidentNumber", c => PreferIncidentNumber(c), required: true),
+ new ExportField("ExposureNumber", c => "000"),
+ new ExportField("IncidentTypeCode", null, required: true), // Resgrid Type is free text, not an NFIRS code
+ new ExportField("AlarmDateTime", c => Utc(c.LoggedOn), required: true),
+ new ExportField("ArrivalDateTime", null), // first on-scene — state-log derived (5b)
+ new ExportField("LastUnitClearedDateTime", c => Utc(c.ClosedOn)),
+ new ExportField("LocationAddress", c => c.Address, required: true),
+ new ExportField("IncidentName", c => c.Name),
+ new ExportField("NatureOfCall", c => c.NatureOfCall),
+ new ExportField("AidGivenOrReceived", null),
+ new ExportField("ActionsTaken", null),
+ new ExportField("PropertyUse", null),
+ };
+
+ // ----- NEMSIS v3 representative field set (EMS) -----
+ private static readonly IReadOnlyList Nemsis = new List
+ {
+ new ExportField("PatientCareReportNumber", null, required: true), // eRecord.01
+ new ExportField("EMSAgencyNumber", null, required: true), // eResponse.03
+ new ExportField("IncidentNumber", c => PreferIncidentNumber(c), required: true), // eResponse.04
+ new ExportField("PSAPCallDateTime", c => Utc(c.LoggedOn), required: true), // eTimes.01
+ new ExportField("UnitNotifiedByDispatchDateTime", c => Utc(c.DispatchOn)), // eTimes.03
+ new ExportField("UnitArrivedOnSceneDateTime", null), // eTimes.06 — state-log derived (5b)
+ new ExportField("UnitLeftSceneDateTime", null), // eTimes.09
+ new ExportField("PatientArrivedAtDestinationDateTime", null), // eTimes.11
+ new ExportField("SceneAddress", c => c.Address), // eScene
+ new ExportField("IncidentNature", c => c.NatureOfCall),
+ new ExportField("Disposition", null), // eDisposition
+ };
+
+ private static string PreferIncidentNumber(Call c)
+ => string.IsNullOrWhiteSpace(c.IncidentNumber) ? c.Number : c.IncidentNumber;
+
+ private static string Int(int value) => value.ToString(CultureInfo.InvariantCulture);
+
+ private static string Utc(DateTime value)
+ => value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
+
+ private static string Utc(DateTime? value)
+ => value.HasValue ? Utc(value.Value) : string.Empty;
+
+ private static string Escape(string value)
+ {
+ value ??= string.Empty;
+ if (value.IndexOf('"') >= 0 || value.IndexOf(',') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0)
+ return "\"" + value.Replace("\"", "\"\"") + "\"";
+ return value;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ReportingMath.cs b/Core/Resgrid.Services/ReportingMath.cs
new file mode 100644
index 000000000..40265bd74
--- /dev/null
+++ b/Core/Resgrid.Services/ReportingMath.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Pure, dependency-free aggregation helpers for reporting (percentiles, mean). Kept separate from
+ /// the data layer so it is fully unit-testable. Percentiles use linear interpolation between the
+ /// closest ranks (the same method as SQL PERCENTILE_CONT).
+ ///
+ public static class ReportingMath
+ {
+ ///
+ /// Computes count, sum, min, max, P50 and P90 over the samples. Returns a zeroed summary for an
+ /// empty/null input. is materialized and sorted internally.
+ ///
+ public static SampleSummary Summarize(IEnumerable samples)
+ {
+ var sorted = (samples ?? Enumerable.Empty()).OrderBy(x => x).ToList();
+ if (sorted.Count == 0)
+ return new SampleSummary();
+
+ return new SampleSummary
+ {
+ Count = sorted.Count,
+ Sum = sorted.Sum(),
+ Min = sorted[0],
+ Max = sorted[sorted.Count - 1],
+ P50 = PercentileSorted(sorted, 0.50),
+ P90 = PercentileSorted(sorted, 0.90)
+ };
+ }
+
+ /// Percentile (0..1) using linear interpolation; must be ascending.
+ public static double PercentileSorted(IReadOnlyList sorted, double percentile)
+ {
+ if (sorted == null || sorted.Count == 0)
+ return 0d;
+ if (sorted.Count == 1)
+ return sorted[0];
+
+ percentile = Math.Min(1d, Math.Max(0d, percentile));
+ var rank = percentile * (sorted.Count - 1);
+ var low = (int)Math.Floor(rank);
+ var high = (int)Math.Ceiling(rank);
+ if (low == high)
+ return sorted[low];
+
+ var weight = rank - low;
+ return sorted[low] + (sorted[high] - sorted[low]) * weight;
+ }
+
+ ///
+ /// Computes committed vs. total in-service seconds from an ascending sequence of classified unit
+ /// states. Each state runs until the next state's timestamp (the last runs to
+ /// ). Total in-service time starts at the first state. Pure and
+ /// unit-testable; the caller pre-classifies each state's committed flag via the availability matrix.
+ ///
+ public static (double committedSeconds, double totalSeconds) UtilizationSeconds(
+ IReadOnlyList<(DateTime Timestamp, bool Committed)> orderedStates, DateTime windowEndUtc)
+ {
+ if (orderedStates == null || orderedStates.Count == 0)
+ return (0d, 0d);
+
+ double committed = 0d, total = 0d;
+ for (var i = 0; i < orderedStates.Count; i++)
+ {
+ var start = orderedStates[i].Timestamp;
+ var end = i + 1 < orderedStates.Count ? orderedStates[i + 1].Timestamp : windowEndUtc;
+ if (end <= start)
+ continue;
+
+ var duration = (end - start).TotalSeconds;
+ total += duration;
+ if (orderedStates[i].Committed)
+ committed += duration;
+ }
+
+ return (committed, total);
+ }
+
+ public class SampleSummary
+ {
+ public long Count { get; set; }
+ public double Sum { get; set; }
+ public double Min { get; set; }
+ public double Max { get; set; }
+ public double P50 { get; set; }
+ public double P90 { get; set; }
+
+ public double Mean => Count > 0 ? Sum / Count : 0d;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ReportingRollupProcessor.cs b/Core/Resgrid.Services/ReportingRollupProcessor.cs
new file mode 100644
index 000000000..0c3f588eb
--- /dev/null
+++ b/Core/Resgrid.Services/ReportingRollupProcessor.cs
@@ -0,0 +1,223 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Reporting;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Computes daily reporting rollups into . v1 computes call
+ /// volume and call-processing (alarm-handling, NFPA 1221) time from the calls themselves. Turnout,
+ /// travel, total-response, UHU and participation are wired extension points
+ /// () — they require per-call/per-unit state-log
+ /// aggregation and are added in the next increment so this job never emits unverified numbers.
+ ///
+ public class ReportingRollupProcessor : IReportingRollupProcessor
+ {
+ private readonly ICallsService _callsService;
+ private readonly IReportingRollupRepository _rollupRepository;
+ private readonly IUnitsService _unitsService;
+ private readonly IUnitStatesService _unitStatesService;
+ private readonly ICustomStateService _customStateService;
+
+ public ReportingRollupProcessor(ICallsService callsService, IReportingRollupRepository rollupRepository,
+ IUnitsService unitsService, IUnitStatesService unitStatesService, ICustomStateService customStateService)
+ {
+ _callsService = callsService;
+ _rollupRepository = rollupRepository;
+ _unitsService = unitsService;
+ _unitStatesService = unitStatesService;
+ _customStateService = customStateService;
+ }
+
+ public async Task RunDailyRollupForDepartmentAsync(int departmentId, DateTime dayUtc, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var (dayStart, dayEnd) = DayBounds(dayUtc);
+ var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, dayStart, dayEnd);
+
+ var rows = BuildCallRollups(calls);
+ rows.AddRange(await ComputeUtilizationRollupsAsync(departmentId, dayStart, dayEnd, cancellationToken));
+
+ return await _rollupRepository.UpsertDailyRollupAsync(departmentId, dayStart, rows, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ReportingRollupProcessor dept={departmentId} day={dayUtc:o}");
+ throw;
+ }
+ }
+
+ public async Task RunDailyRollupForAllAsync(DateTime dayUtc, IEnumerable departmentIds, CancellationToken cancellationToken = default)
+ {
+ var (dayStart, dayEnd) = DayBounds(dayUtc);
+ var written = 0;
+
+ long systemCallCount = 0;
+ var systemProcessing = new List();
+
+ foreach (var departmentId in (departmentIds ?? Enumerable.Empty()).Distinct())
+ {
+ try
+ {
+ var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, dayStart, dayEnd);
+
+ var rows = BuildCallRollups(calls);
+ rows.AddRange(await ComputeUtilizationRollupsAsync(departmentId, dayStart, dayEnd, cancellationToken));
+ written += await _rollupRepository.UpsertDailyRollupAsync(departmentId, dayStart, rows, cancellationToken);
+
+ systemCallCount += calls.Count;
+ systemProcessing.AddRange(CallProcessingSamples(calls));
+ }
+ catch (Exception ex)
+ {
+ // One department's failure must not abort the nightly batch; log it and continue so the
+ // remaining departments and the system-wide aggregate still complete.
+ Logging.LogException(ex, $"ReportingRollupProcessor dept={departmentId} day={dayUtc:o}");
+ }
+ }
+
+ // System-wide aggregate row (DepartmentId null) from the combined samples.
+ var systemRows = new List
+ {
+ new ReportingDailyRollup { Metric = ReportingMetrics.CallCount, ItemCount = systemCallCount }
+ };
+ if (systemProcessing.Count > 0)
+ systemRows.Add(ToRow(ReportingMetrics.CallProcessingSeconds, ReportingMath.Summarize(systemProcessing)));
+
+ written += await _rollupRepository.UpsertDailyRollupAsync(null, dayStart, systemRows, cancellationToken);
+
+ return written;
+ }
+
+ #region Computation
+
+ private static List BuildCallRollups(IEnumerable calls)
+ {
+ var callList = calls?.ToList() ?? new List();
+ var rows = new List
+ {
+ new ReportingDailyRollup { Metric = ReportingMetrics.CallCount, ItemCount = callList.Count }
+ };
+
+ var processing = CallProcessingSamples(callList);
+ if (processing.Count > 0)
+ rows.Add(ToRow(ReportingMetrics.CallProcessingSeconds, ReportingMath.Summarize(processing)));
+
+ return rows;
+ }
+
+ // Alarm handling / call processing time (NFPA 1221): LoggedOn -> DispatchOn, in seconds.
+ private static List CallProcessingSamples(IEnumerable calls)
+ {
+ return (calls ?? Enumerable.Empty())
+ .Where(c => c.DispatchOn.HasValue && c.DispatchOn.Value > c.LoggedOn)
+ .Select(c => (c.DispatchOn.Value - c.LoggedOn).TotalSeconds)
+ .ToList();
+ }
+
+ ///
+ /// Computes Unit Hour Utilization for the department/day from each unit's state durations
+ /// (committed time / in-service time), classifying each state via the availability matrix
+ /// (custom unit statuses resolved through CustomStateDetail.BaseType). Emits a single aggregate
+ /// "Uhu" row whose mean is SumValue / ItemCount.
+ ///
+ /// Turnout/travel/total-response (per-call state transitions) and per-member participation remain
+ /// follow-up items: their call-state-linkage semantics need verification against a live DB before
+ /// numbers are emitted, so they are intentionally not computed here.
+ ///
+ private async Task> ComputeUtilizationRollupsAsync(int departmentId, DateTime dayStart, DateTime dayEnd, CancellationToken cancellationToken)
+ {
+ var rows = new List();
+
+ var units = await _unitsService.GetUnitsForDepartmentAsync(departmentId);
+ if (units == null || units.Count == 0)
+ return rows;
+
+ var unitCustomBaseMap = await GetUnitCustomBaseMapAsync(departmentId);
+
+ double sumUhu = 0d;
+ var unitCount = 0;
+
+ foreach (var unit in units)
+ {
+ var states = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unit.UnitId, dayStart, dayEnd);
+ if (states == null || states.Count == 0)
+ continue;
+
+ var ordered = states
+ .OrderBy(s => s.Timestamp)
+ .Select(s => (s.Timestamp, Committed: IsUnitStateCommitted(s.State, unitCustomBaseMap)))
+ .ToList();
+
+ var (committed, total) = ReportingMath.UtilizationSeconds(ordered, dayEnd);
+ if (total <= 0d)
+ continue;
+
+ sumUhu += committed / total;
+ unitCount++;
+ }
+
+ if (unitCount > 0)
+ rows.Add(new ReportingDailyRollup { Metric = ReportingMetrics.UnitHourUtilization, ItemCount = unitCount, SumValue = (decimal)sumUhu });
+
+ return rows;
+ }
+
+ // A unit's raw state is either a built-in UnitStateTypes value or a CustomStateDetailId; custom
+ // states resolve to a canonical base via CustomStateDetail.BaseType.
+ private static bool IsUnitStateCommitted(int rawState, IReadOnlyDictionary customBaseMap)
+ {
+ var availability = customBaseMap != null && customBaseMap.TryGetValue(rawState, out var baseType)
+ ? AvailabilityMatrix.ForCustomBaseType(baseType)
+ : AvailabilityMatrix.ForUnitStateType(rawState);
+ return availability == AvailabilityClass.Committed;
+ }
+
+ private async Task> GetUnitCustomBaseMapAsync(int departmentId)
+ {
+ var map = new Dictionary();
+ var states = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId);
+ if (states == null)
+ return map;
+
+ foreach (var state in states.Where(s => s.Type == (int)CustomStateTypes.Unit))
+ {
+ foreach (var detail in state.GetActiveDetails())
+ map[detail.CustomStateDetailId] = detail.BaseType;
+ }
+
+ return map;
+ }
+
+ private static ReportingDailyRollup ToRow(string metric, ReportingMath.SampleSummary summary, string dimension = null)
+ {
+ return new ReportingDailyRollup
+ {
+ Metric = metric,
+ Dimension = dimension,
+ ItemCount = summary.Count,
+ SumValue = (decimal)summary.Sum,
+ MinValue = (decimal)summary.Min,
+ MaxValue = (decimal)summary.Max,
+ P50 = (decimal)summary.P50,
+ P90 = (decimal)summary.P90
+ };
+ }
+
+ private static (DateTime start, DateTime end) DayBounds(DateTime dayUtc)
+ {
+ var start = dayUtc.Date;
+ return (start, start.AddDays(1).AddTicks(-1));
+ }
+
+ #endregion
+ }
+}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 8cf8e90dc..5bdf32ea9 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -36,6 +36,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0073_AddingReportingIndexes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0073_AddingReportingIndexes.cs
new file mode 100644
index 000000000..d36b00f82
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0073_AddingReportingIndexes.cs
@@ -0,0 +1,100 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ ///
+ /// Reporting/analytics support: a pre-aggregated daily rollup table for heavy analytics
+ /// (response times, UHU, participation) plus covering indexes on the source tables that the
+ /// live dashboard aggregates GROUP BY / filter on. Index creation is guarded so it is safe if a
+ /// matching index already exists.
+ ///
+ [Migration(73)]
+ public class M0073_AddingReportingIndexes : Migration
+ {
+ public override void Up()
+ {
+ // Pre-aggregated daily rollup (DepartmentId null => system-wide row).
+ Create.Table("ReportingDailyRollup")
+ .WithColumn("ReportingDailyRollupId").AsInt64().NotNullable().PrimaryKey().Identity()
+ .WithColumn("DepartmentId").AsInt32().Nullable()
+ .WithColumn("BucketDateUtc").AsDateTime2().NotNullable()
+ .WithColumn("Metric").AsString(128).NotNullable()
+ .WithColumn("Dimension").AsString(256).Nullable()
+ .WithColumn("ItemCount").AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("SumValue").AsDecimal(18, 4).Nullable()
+ .WithColumn("MinValue").AsDecimal(18, 4).Nullable()
+ .WithColumn("MaxValue").AsDecimal(18, 4).Nullable()
+ .WithColumn("P50").AsDecimal(18, 4).Nullable()
+ .WithColumn("P90").AsDecimal(18, 4).Nullable()
+ .WithColumn("CreatedOnUtc").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("IX_ReportingDailyRollup_Dept_Date_Metric")
+ .OnTable("ReportingDailyRollup")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("BucketDateUtc").Ascending()
+ .OnColumn("Metric").Ascending();
+
+ // Source-table covering indexes for the live dashboard aggregates.
+ if (!Schema.Table("Calls").Index("IX_Calls_DepartmentId_LoggedOn").Exists())
+ Create.Index("IX_Calls_DepartmentId_LoggedOn").OnTable("Calls")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("LoggedOn").Ascending();
+
+ if (!Schema.Table("Calls").Index("IX_Calls_DepartmentId_Type").Exists())
+ Create.Index("IX_Calls_DepartmentId_Type").OnTable("Calls")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("Type").Ascending();
+
+ if (!Schema.Table("Calls").Index("IX_Calls_DepartmentId_Priority").Exists())
+ Create.Index("IX_Calls_DepartmentId_Priority").OnTable("Calls")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("Priority").Ascending();
+
+ if (!Schema.Table("Calls").Index("IX_Calls_DepartmentId_State").Exists())
+ Create.Index("IX_Calls_DepartmentId_State").OnTable("Calls")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("State").Ascending();
+
+ if (!Schema.Table("ActionLogs").Index("IX_ActionLogs_Dept_User_Timestamp").Exists())
+ Create.Index("IX_ActionLogs_Dept_User_Timestamp").OnTable("ActionLogs")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("UserId").Ascending()
+ .OnColumn("Timestamp").Descending();
+
+ if (!Schema.Table("UnitStates").Index("IX_UnitStates_Unit_Timestamp").Exists())
+ Create.Index("IX_UnitStates_Unit_Timestamp").OnTable("UnitStates")
+ .OnColumn("UnitId").Ascending()
+ .OnColumn("Timestamp").Descending();
+
+ if (!Schema.Table("Units").Index("IX_Units_DepartmentId").Exists())
+ Create.Index("IX_Units_DepartmentId").OnTable("Units")
+ .OnColumn("DepartmentId").Ascending();
+
+ if (!Schema.Table("DepartmentMembers").Index("IX_DepartmentMembers_DepartmentId").Exists())
+ Create.Index("IX_DepartmentMembers_DepartmentId").OnTable("DepartmentMembers")
+ .OnColumn("DepartmentId").Ascending();
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("DepartmentMembers").Index("IX_DepartmentMembers_DepartmentId").Exists())
+ Delete.Index("IX_DepartmentMembers_DepartmentId").OnTable("DepartmentMembers");
+ if (Schema.Table("Units").Index("IX_Units_DepartmentId").Exists())
+ Delete.Index("IX_Units_DepartmentId").OnTable("Units");
+ if (Schema.Table("UnitStates").Index("IX_UnitStates_Unit_Timestamp").Exists())
+ Delete.Index("IX_UnitStates_Unit_Timestamp").OnTable("UnitStates");
+ if (Schema.Table("ActionLogs").Index("IX_ActionLogs_Dept_User_Timestamp").Exists())
+ Delete.Index("IX_ActionLogs_Dept_User_Timestamp").OnTable("ActionLogs");
+ if (Schema.Table("Calls").Index("IX_Calls_DepartmentId_State").Exists())
+ Delete.Index("IX_Calls_DepartmentId_State").OnTable("Calls");
+ if (Schema.Table("Calls").Index("IX_Calls_DepartmentId_Priority").Exists())
+ Delete.Index("IX_Calls_DepartmentId_Priority").OnTable("Calls");
+ if (Schema.Table("Calls").Index("IX_Calls_DepartmentId_Type").Exists())
+ Delete.Index("IX_Calls_DepartmentId_Type").OnTable("Calls");
+ if (Schema.Table("Calls").Index("IX_Calls_DepartmentId_LoggedOn").Exists())
+ Delete.Index("IX_Calls_DepartmentId_LoggedOn").OnTable("Calls");
+
+ Delete.Table("ReportingDailyRollup");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0073_AddingReportingIndexesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0073_AddingReportingIndexesPg.cs
new file mode 100644
index 000000000..efbc9d9dd
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0073_AddingReportingIndexesPg.cs
@@ -0,0 +1,99 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ ///
+ /// PostgreSQL variant of the reporting/analytics support: pre-aggregated daily rollup table plus
+ /// covering indexes on the source tables. All identifiers are lowercased per the PG convention.
+ /// Index creation is guarded so it is safe if a matching index already exists.
+ ///
+ [Migration(73)]
+ public class M0073_AddingReportingIndexesPg : Migration
+ {
+ public override void Up()
+ {
+ // Pre-aggregated daily rollup (DepartmentId null => system-wide row).
+ Create.Table("ReportingDailyRollup".ToLower())
+ .WithColumn("ReportingDailyRollupId".ToLower()).AsInt64().NotNullable().PrimaryKey().Identity()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().Nullable()
+ .WithColumn("BucketDateUtc".ToLower()).AsDateTime2().NotNullable()
+ .WithColumn("Metric".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("Dimension".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ItemCount".ToLower()).AsInt64().NotNullable().WithDefaultValue(0)
+ .WithColumn("SumValue".ToLower()).AsDecimal(18, 4).Nullable()
+ .WithColumn("MinValue".ToLower()).AsDecimal(18, 4).Nullable()
+ .WithColumn("MaxValue".ToLower()).AsDecimal(18, 4).Nullable()
+ .WithColumn("P50".ToLower()).AsDecimal(18, 4).Nullable()
+ .WithColumn("P90".ToLower()).AsDecimal(18, 4).Nullable()
+ .WithColumn("CreatedOnUtc".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("IX_ReportingDailyRollup_Dept_Date_Metric".ToLower())
+ .OnTable("ReportingDailyRollup".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("BucketDateUtc".ToLower()).Ascending()
+ .OnColumn("Metric".ToLower()).Ascending();
+
+ // Source-table covering indexes for the live dashboard aggregates.
+ if (!Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_LoggedOn".ToLower()).Exists())
+ Create.Index("IX_Calls_DepartmentId_LoggedOn".ToLower()).OnTable("Calls".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("LoggedOn".ToLower()).Ascending();
+
+ if (!Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_Type".ToLower()).Exists())
+ Create.Index("IX_Calls_DepartmentId_Type".ToLower()).OnTable("Calls".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("Type".ToLower()).Ascending();
+
+ if (!Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_Priority".ToLower()).Exists())
+ Create.Index("IX_Calls_DepartmentId_Priority".ToLower()).OnTable("Calls".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("Priority".ToLower()).Ascending();
+
+ if (!Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_State".ToLower()).Exists())
+ Create.Index("IX_Calls_DepartmentId_State".ToLower()).OnTable("Calls".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("State".ToLower()).Ascending();
+
+ if (!Schema.Table("ActionLogs".ToLower()).Index("IX_ActionLogs_Dept_User_Timestamp".ToLower()).Exists())
+ Create.Index("IX_ActionLogs_Dept_User_Timestamp".ToLower()).OnTable("ActionLogs".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("UserId".ToLower()).Ascending()
+ .OnColumn("Timestamp".ToLower()).Descending();
+
+ if (!Schema.Table("UnitStates".ToLower()).Index("IX_UnitStates_Unit_Timestamp".ToLower()).Exists())
+ Create.Index("IX_UnitStates_Unit_Timestamp".ToLower()).OnTable("UnitStates".ToLower())
+ .OnColumn("UnitId".ToLower()).Ascending()
+ .OnColumn("Timestamp".ToLower()).Descending();
+
+ if (!Schema.Table("Units".ToLower()).Index("IX_Units_DepartmentId".ToLower()).Exists())
+ Create.Index("IX_Units_DepartmentId".ToLower()).OnTable("Units".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending();
+
+ if (!Schema.Table("DepartmentMembers".ToLower()).Index("IX_DepartmentMembers_DepartmentId".ToLower()).Exists())
+ Create.Index("IX_DepartmentMembers_DepartmentId".ToLower()).OnTable("DepartmentMembers".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending();
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("DepartmentMembers".ToLower()).Index("IX_DepartmentMembers_DepartmentId".ToLower()).Exists())
+ Delete.Index("IX_DepartmentMembers_DepartmentId".ToLower()).OnTable("DepartmentMembers".ToLower());
+ if (Schema.Table("Units".ToLower()).Index("IX_Units_DepartmentId".ToLower()).Exists())
+ Delete.Index("IX_Units_DepartmentId".ToLower()).OnTable("Units".ToLower());
+ if (Schema.Table("UnitStates".ToLower()).Index("IX_UnitStates_Unit_Timestamp".ToLower()).Exists())
+ Delete.Index("IX_UnitStates_Unit_Timestamp".ToLower()).OnTable("UnitStates".ToLower());
+ if (Schema.Table("ActionLogs".ToLower()).Index("IX_ActionLogs_Dept_User_Timestamp".ToLower()).Exists())
+ Delete.Index("IX_ActionLogs_Dept_User_Timestamp".ToLower()).OnTable("ActionLogs".ToLower());
+ if (Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_State".ToLower()).Exists())
+ Delete.Index("IX_Calls_DepartmentId_State".ToLower()).OnTable("Calls".ToLower());
+ if (Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_Priority".ToLower()).Exists())
+ Delete.Index("IX_Calls_DepartmentId_Priority".ToLower()).OnTable("Calls".ToLower());
+ if (Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_Type".ToLower()).Exists())
+ Delete.Index("IX_Calls_DepartmentId_Type".ToLower()).OnTable("Calls".ToLower());
+ if (Schema.Table("Calls".ToLower()).Index("IX_Calls_DepartmentId_LoggedOn".ToLower()).Exists())
+ Delete.Index("IX_Calls_DepartmentId_LoggedOn".ToLower()).OnTable("Calls".ToLower());
+
+ Delete.Table("ReportingDailyRollup".ToLower());
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
index 36bde069c..cf7905ad7 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
@@ -315,6 +315,29 @@ protected SqlConfiguration() { }
public string SelectCallAttachmentByCallIdTypeQuery { get; set; }
public string SelectAllOpenCallsByDidDateQuery { get; set; }
public string SelectAllCallsByDidLoggedOnQuery { get; set; }
+
+ // Platform reporting / analytics aggregate queries (set-based; dual-scope via %ALLDEPTS%).
+ public string SelectReportCallsCountQuery { get; set; }
+ public string SelectReportCallsCountAllTimeQuery { get; set; }
+ public string SelectReportActiveCallsCountQuery { get; set; }
+ public string SelectReportPersonnelCountQuery { get; set; }
+ public string SelectReportUnitsCountQuery { get; set; }
+ public string SelectReportCallsByDayQuery { get; set; }
+ public string SelectReportCallsByMonthQuery { get; set; }
+ public string SelectReportCallsByTypeQuery { get; set; }
+ public string SelectReportCallsByPriorityQuery { get; set; }
+ public string SelectReportCallsByStateQuery { get; set; }
+ public string SelectReportLatestPersonnelStatesQuery { get; set; }
+ public string SelectReportLatestUnitStatesQuery { get; set; }
+ public string ReportingDailyRollupTable { get; set; }
+ public string SelectReportRollupsQuery { get; set; }
+ public string DeleteReportRollupForDateQuery { get; set; }
+ public string InsertReportRollupQuery { get; set; }
+ public string SelectReportMessagesCountQuery { get; set; }
+ public string SelectReportMessagesByDayQuery { get; set; }
+ public string SelectReportMessagesByMonthQuery { get; set; }
+ public string SelectReportDepartmentsTotalQuery { get; set; }
+ public string SelectReportNewDepartmentsQuery { get; set; }
public string UpdateUserDispatchesAsSentQuery { get; set; }
public string SelectActiveCallsWithCheckInTimersForUserQuery { get; set; }
public string SelectCallProtocolsByCallIdQuery { get; set; }
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
index d805672b4..361a1411a 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
@@ -42,6 +42,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index ca946e273..946e09df8 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -46,6 +46,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
index be27bb3f5..21c124471 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
@@ -41,6 +41,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
index 7bc837f74..e252f5a1b 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
@@ -41,6 +41,8 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/DeleteReportRollupForDateQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/DeleteReportRollupForDateQuery.cs
new file mode 100644
index 000000000..3be6adc66
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/DeleteReportRollupForDateQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class DeleteReportRollupForDateQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public DeleteReportRollupForDateQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.DeleteReportRollupForDateQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.ReportingDailyRollupTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%BUCKETDATE%", "%HASDEPT%", "%DID%" },
+ new string[] { "BucketDate", "HasDept", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/InsertReportRollupQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/InsertReportRollupQuery.cs
new file mode 100644
index 000000000..d47c8bde7
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/InsertReportRollupQuery.cs
@@ -0,0 +1,33 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class InsertReportRollupQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public InsertReportRollupQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ // Values are bound from the ReportingDailyRollup object by Dapper (literal @params);
+ // only schema/table tokens need substitution here.
+ return _sqlConfiguration.InsertReportRollupQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.ReportingDailyRollupTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { },
+ new string[] { });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportActiveCallsCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportActiveCallsCountQuery.cs
new file mode 100644
index 000000000..9e5b112d4
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportActiveCallsCountQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportActiveCallsCountQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportActiveCallsCountQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportActiveCallsCountQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByDayQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByDayQuery.cs
new file mode 100644
index 000000000..9ce5fd723
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByDayQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsByDayQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsByDayQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsByDayQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByMonthQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByMonthQuery.cs
new file mode 100644
index 000000000..ff2e7ca2c
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByMonthQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsByMonthQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsByMonthQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsByMonthQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByPriorityQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByPriorityQuery.cs
new file mode 100644
index 000000000..a11a310af
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByPriorityQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsByPriorityQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsByPriorityQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsByPriorityQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByStateQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByStateQuery.cs
new file mode 100644
index 000000000..5a9cd0124
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByStateQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsByStateQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsByStateQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsByStateQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByTypeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByTypeQuery.cs
new file mode 100644
index 000000000..c1c0286af
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsByTypeQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsByTypeQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsByTypeQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsByTypeQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountAllTimeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountAllTimeQuery.cs
new file mode 100644
index 000000000..586d4cd06
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountAllTimeQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsCountAllTimeQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsCountAllTimeQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsCountAllTimeQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountQuery.cs
new file mode 100644
index 000000000..1a13bbf99
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportCallsCountQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportCallsCountQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportCallsCountQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportCallsCountQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CallsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportDepartmentsTotalQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportDepartmentsTotalQuery.cs
new file mode 100644
index 000000000..3cac41b43
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportDepartmentsTotalQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportDepartmentsTotalQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportDepartmentsTotalQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportDepartmentsTotalQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.DepartmentsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { },
+ new string[] { });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestPersonnelStatesQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestPersonnelStatesQuery.cs
new file mode 100644
index 000000000..1a1cc8abb
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestPersonnelStatesQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportLatestPersonnelStatesQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportLatestPersonnelStatesQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportLatestPersonnelStatesQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.ActionLogsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestUnitStatesQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestUnitStatesQuery.cs
new file mode 100644
index 000000000..ec95b603b
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportLatestUnitStatesQuery.cs
@@ -0,0 +1,34 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportLatestUnitStatesQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportLatestUnitStatesQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ // UnitState has no DepartmentId; scope is applied by joining Units (%UNITSTABLE%).
+ return _sqlConfiguration.SelectReportLatestUnitStatesQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.UnitStatesTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" },
+ new string[] { "%UNITSTABLE%" },
+ new string[] { _sqlConfiguration.UnitsTable });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByDayQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByDayQuery.cs
new file mode 100644
index 000000000..ae7305762
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByDayQuery.cs
@@ -0,0 +1,33 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportMessagesByDayQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportMessagesByDayQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportMessagesByDayQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.MessagesTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" },
+ new string[] { "%MEMBERSTABLE%" },
+ new string[] { _sqlConfiguration.DepartmentMembersTable });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByMonthQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByMonthQuery.cs
new file mode 100644
index 000000000..a323a2434
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesByMonthQuery.cs
@@ -0,0 +1,33 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportMessagesByMonthQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportMessagesByMonthQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportMessagesByMonthQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.MessagesTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" },
+ new string[] { "%MEMBERSTABLE%" },
+ new string[] { _sqlConfiguration.DepartmentMembersTable });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesCountQuery.cs
new file mode 100644
index 000000000..fdcabd414
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportMessagesCountQuery.cs
@@ -0,0 +1,33 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportMessagesCountQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportMessagesCountQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportMessagesCountQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.MessagesTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%", "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "AllDepts", "DepartmentId", "StartDate", "EndDate" },
+ new string[] { "%MEMBERSTABLE%" },
+ new string[] { _sqlConfiguration.DepartmentMembersTable });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportNewDepartmentsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportNewDepartmentsQuery.cs
new file mode 100644
index 000000000..bbf4dc0a2
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportNewDepartmentsQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportNewDepartmentsQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportNewDepartmentsQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportNewDepartmentsQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.DepartmentsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%STARTDATE%", "%ENDDATE%" },
+ new string[] { "StartDate", "EndDate" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportPersonnelCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportPersonnelCountQuery.cs
new file mode 100644
index 000000000..4931462ee
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportPersonnelCountQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportPersonnelCountQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportPersonnelCountQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportPersonnelCountQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.DepartmentMembersTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportRollupsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportRollupsQuery.cs
new file mode 100644
index 000000000..e9df4da2a
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportRollupsQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportRollupsQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportRollupsQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportRollupsQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.ReportingDailyRollupTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%STARTDATE%", "%ENDDATE%", "%METRIC%", "%HASDEPT%", "%DID%" },
+ new string[] { "StartDate", "EndDate", "Metric", "HasDept", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportUnitsCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportUnitsCountQuery.cs
new file mode 100644
index 000000000..f6f91d721
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Reporting/SelectReportUnitsCountQuery.cs
@@ -0,0 +1,31 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries.Contracts;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Extensions;
+
+namespace Resgrid.Repositories.DataRepository.Queries.Reporting
+{
+ public class SelectReportUnitsCountQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectReportUnitsCountQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ return _sqlConfiguration.SelectReportUnitsCountQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.UnitsTable,
+ _sqlConfiguration.ParameterNotation,
+ new string[] { "%ALLDEPTS%", "%DID%" },
+ new string[] { "AllDepts", "DepartmentId" });
+ }
+
+ public string GetQuery() where TEntity : class, IEntity
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ReportingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ReportingRepository.cs
new file mode 100644
index 000000000..7080885ea
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/ReportingRepository.cs
@@ -0,0 +1,276 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Dapper;
+using Resgrid.Framework;
+using Resgrid.Model.Reporting;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+using Resgrid.Repositories.DataRepository.Queries.Reporting;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ ///
+ /// Set-based aggregate data access for platform reporting. Entity-less (does not extend
+ /// RepositoryBase<T>) because every method returns aggregates, never materialized rows.
+ /// A null departmentId means system-wide: passed to SQL as %ALLDEPTS% = 1.
+ ///
+ public class ReportingRepository : IReportingRepository
+ {
+ private readonly IConnectionProvider _connectionProvider;
+ private readonly SqlConfiguration _sqlConfiguration;
+ private readonly IQueryFactory _queryFactory;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public ReportingRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration,
+ IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ {
+ _connectionProvider = connectionProvider;
+ _sqlConfiguration = sqlConfiguration;
+ _queryFactory = queryFactory;
+ _unitOfWork = unitOfWork;
+ }
+
+ #region Scalar totals
+
+ public async Task GetCallsCountAsync(int? departmentId, DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = ScopeParams(departmentId);
+ string query;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ p.Add("StartDate", startUtc.Value);
+ p.Add("EndDate", endUtc.Value);
+ query = _queryFactory.GetQuery();
+ }
+ else
+ {
+ query = _queryFactory.GetQuery();
+ }
+
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(command);
+ });
+ }
+
+ public async Task GetActiveCallsCountAsync(int? departmentId, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = ScopeParams(departmentId);
+ var query = _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(command);
+ });
+ }
+
+ public async Task GetPersonnelCountAsync(int? departmentId, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = ScopeParams(departmentId);
+ var query = _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(command);
+ });
+ }
+
+ public async Task GetUnitsCountAsync(int? departmentId, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = ScopeParams(departmentId);
+ var query = _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(command);
+ });
+ }
+
+ public async Task GetMessagesCountAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = WindowScopeParams(departmentId, startUtc, endUtc);
+ var query = _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(command);
+ });
+ }
+
+ // No creation-date column exists on DepartmentMember/IdentityUser/UserProfile, so a "new users in
+ // window" metric cannot be derived from the current schema. Returns 0 until such a column exists.
+ public Task GetNewUsersCountAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
+ => Task.FromResult(0L);
+
+ public async Task GetDepartmentsCountAsync(DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ var p = new DynamicParametersExtension();
+ p.Add("StartDate", startUtc.Value);
+ p.Add("EndDate", endUtc.Value);
+ var newQuery = _queryFactory.GetQuery();
+ var newCommand = new CommandDefinition(newQuery, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(newCommand);
+ }
+
+ var totalQuery = _queryFactory.GetQuery();
+ var totalCommand = new CommandDefinition(totalQuery, new DynamicParametersExtension(), _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.ExecuteScalarAsync(totalCommand);
+ });
+ }
+
+ #endregion
+
+ #region Time-bucketed series
+
+ public async Task> GetCallsByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = WindowScopeParams(departmentId, startUtc, endUtc);
+ var query = granularity == ReportGranularity.Month
+ ? _queryFactory.GetQuery()
+ : _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.QueryAsync(command);
+ });
+ }
+
+ public async Task> GetMessagesByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = WindowScopeParams(departmentId, startUtc, endUtc);
+ var query = granularity == ReportGranularity.Month
+ ? _queryFactory.GetQuery()
+ : _queryFactory.GetQuery();
+ var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken);
+ return await conn.QueryAsync(command);
+ });
+ }
+
+ // See GetNewUsersCountAsync: no source creation-date column exists, so the series is empty.
+ public Task> GetNewUsersByDateBucketAsync(int? departmentId, DateTime startUtc, DateTime endUtc, ReportGranularity granularity, CancellationToken cancellationToken = default)
+ => Task.FromResult(Enumerable.Empty());
+
+ #endregion
+
+ #region Breakdowns
+
+ public async Task> GetCallsBreakdownByTypeAsync(int? departmentId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
+ {
+ return await RunAsync(async conn =>
+ {
+ var p = WindowScopeParams(departmentId, startUtc, endUtc);
+ var query = _queryFactory.GetQuery