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(); + var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken); + return await conn.QueryAsync(command); + }); + } + + public async Task> GetCallsBreakdownByPriorityAsync(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.QueryAsync(command); + }); + } + + public async Task> GetCallsBreakdownByStateAsync(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.QueryAsync(command); + }); + } + + #endregion + + #region Latest-state counts + + public async Task> GetLatestPersonnelStateCountsAsync(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.QueryAsync(command); + }); + } + + public async Task> GetLatestUnitStateCountsAsync(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.QueryAsync(command); + }); + } + + #endregion + + #region Helpers + + // Scope parameters: AllDepts = 1 (system-wide) when departmentId is null; otherwise filter to it. + private static DynamicParametersExtension ScopeParams(int? departmentId) + { + var p = new DynamicParametersExtension(); + p.Add("AllDepts", departmentId.HasValue ? 0 : 1); + p.Add("DepartmentId", departmentId ?? 0); + return p; + } + + private static DynamicParametersExtension WindowScopeParams(int? departmentId, DateTime startUtc, DateTime endUtc) + { + var p = ScopeParams(departmentId); + p.Add("StartDate", startUtc); + p.Add("EndDate", endUtc); + return p; + } + + private async Task RunAsync(Func> selectFunction) + { + try + { + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + #endregion + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ReportingRollupRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ReportingRollupRepository.cs new file mode 100644 index 000000000..a37840a1d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ReportingRollupRepository.cs @@ -0,0 +1,115 @@ +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 +{ + /// + /// Dapper-backed read/write for the store. Entity-less; uses the + /// templated reporting queries. Upsert is delete-then-insert per (department, day) for idempotency. + /// + public class ReportingRollupRepository : IReportingRollupRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public ReportingRollupRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task UpsertDailyRollupAsync(int? departmentId, DateTime bucketDateUtc, + IEnumerable rows, CancellationToken cancellationToken = default) + { + var rowList = (rows ?? Enumerable.Empty()).ToList(); + var now = DateTime.UtcNow; + foreach (var row in rowList) + { + row.DepartmentId = departmentId; + row.BucketDateUtc = bucketDateUtc; + if (row.CreatedOnUtc == default) + row.CreatedOnUtc = now; + } + + return await RunAsync(async conn => + { + var deleteParams = new DynamicParametersExtension(); + deleteParams.Add("BucketDate", bucketDateUtc); + deleteParams.Add("HasDept", departmentId.HasValue ? 1 : 0); + deleteParams.Add("DepartmentId", departmentId ?? 0); + + var deleteQuery = _queryFactory.GetQuery(); + var deleteCommand = new CommandDefinition(deleteQuery, deleteParams, _unitOfWork.Transaction, cancellationToken: cancellationToken); + await conn.ExecuteAsync(deleteCommand); + + if (rowList.Count > 0) + { + var insertQuery = _queryFactory.GetQuery(); + var insertCommand = new CommandDefinition(insertQuery, rowList, _unitOfWork.Transaction, cancellationToken: cancellationToken); + await conn.ExecuteAsync(insertCommand); + } + + return rowList.Count; + }); + } + + public async Task> GetRollupsAsync(int? departmentId, DateTime startUtc, + DateTime endUtc, string metric, CancellationToken cancellationToken = default) + { + return await RunAsync(async conn => + { + var p = new DynamicParametersExtension(); + p.Add("StartDate", startUtc); + p.Add("EndDate", endUtc); + p.Add("Metric", metric); + p.Add("HasDept", departmentId.HasValue ? 1 : 0); + p.Add("DepartmentId", departmentId ?? 0); + + var query = _queryFactory.GetQuery(); + var command = new CommandDefinition(query, p, _unitOfWork.Transaction, cancellationToken: cancellationToken); + return await conn.QueryAsync(command); + }); + } + + private async Task RunAsync(Func> selectFunction) + { + try + { + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index e2ba300b2..7ffbb1adb 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1176,6 +1176,52 @@ from shifts sh "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND IsDeleted = false AND State = 0"; SelectAllCallsByDidLoggedOnQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND AND LoggedOn >= %DATE%"; + + // ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) ----- + SelectReportCallsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE%"; + SelectReportCallsCountAllTimeQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false"; + SelectReportActiveCallsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND State = 0"; + SelectReportPersonnelCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND (IsDisabled IS NULL OR IsDisabled = false)"; + SelectReportUnitsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%)"; + SelectReportCallsByDayQuery = + "SELECT date_trunc('day', LoggedOn) AS Bucket, COUNT(*) AS Total FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE% GROUP BY date_trunc('day', LoggedOn)"; + SelectReportCallsByMonthQuery = + "SELECT date_trunc('month', LoggedOn) AS Bucket, COUNT(*) AS Total FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE% GROUP BY date_trunc('month', LoggedOn)"; + SelectReportCallsByTypeQuery = + "SELECT Type AS GroupKey, COUNT(*) AS Total FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE% GROUP BY Type"; + SelectReportCallsByPriorityQuery = + "SELECT Priority AS GroupKey, COUNT(*) AS Total FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE% GROUP BY Priority"; + SelectReportCallsByStateQuery = + "SELECT State AS GroupKey, COUNT(*) AS Total FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR DepartmentId = %DID%) AND IsDeleted = false AND LoggedOn >= %STARTDATE% AND LoggedOn <= %ENDDATE% GROUP BY State"; + // Latest-state-per-entity counts grouped by raw status id (built-in enum OR CustomStateDetailId). + SelectReportLatestPersonnelStatesQuery = + "SELECT latest.ActionTypeId AS GroupKey, COUNT(*) AS Total FROM (SELECT al.ActionTypeId, ROW_NUMBER() OVER (PARTITION BY al.UserId ORDER BY al.Timestamp DESC) AS rn FROM %SCHEMA%.%TABLENAME% al WHERE (%ALLDEPTS% = 1 OR al.DepartmentId = %DID%)) latest WHERE latest.rn = 1 GROUP BY latest.ActionTypeId"; + SelectReportLatestUnitStatesQuery = + "SELECT latest.State AS GroupKey, COUNT(*) AS Total FROM (SELECT us.State, ROW_NUMBER() OVER (PARTITION BY us.UnitId ORDER BY us.Timestamp DESC) AS rn FROM %SCHEMA%.%TABLENAME% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.UnitId = us.UnitId WHERE (%ALLDEPTS% = 1 OR u.DepartmentId = %DID%)) latest WHERE latest.rn = 1 GROUP BY latest.State"; + ReportingDailyRollupTable = "ReportingDailyRollup"; + SelectReportRollupsQuery = + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE BucketDateUtc >= %STARTDATE% AND BucketDateUtc <= %ENDDATE% AND Metric = %METRIC% AND ((%HASDEPT% = 0 AND DepartmentId IS NULL) OR (%HASDEPT% = 1 AND DepartmentId = %DID%))"; + DeleteReportRollupForDateQuery = + "DELETE FROM %SCHEMA%.%TABLENAME% WHERE BucketDateUtc = %BUCKETDATE% AND ((%HASDEPT% = 0 AND DepartmentId IS NULL) OR (%HASDEPT% = 1 AND DepartmentId = %DID%))"; + InsertReportRollupQuery = + "INSERT INTO %SCHEMA%.%TABLENAME% (DepartmentId,BucketDateUtc,Metric,Dimension,ItemCount,SumValue,MinValue,MaxValue,P50,P90,CreatedOnUtc) VALUES (@DepartmentId,@BucketDateUtc,@Metric,@Dimension,@ItemCount,@SumValue,@MinValue,@MaxValue,@P50,@P90,@CreatedOnUtc)"; + // Messages have no DepartmentId; scope via a LEFT JOIN to DepartmentMembers on the sender, and + // COUNT(DISTINCT MessageId) so system-wide (%ALLDEPTS%=1) counts each message once. + SelectReportMessagesCountQuery = + "SELECT COUNT(DISTINCT m.MessageId) FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.UserId = m.SendingUserId WHERE (%ALLDEPTS% = 1 OR dm.DepartmentId = %DID%) AND m.IsDeleted = false AND m.SentOn >= %STARTDATE% AND m.SentOn <= %ENDDATE%"; + SelectReportMessagesByDayQuery = + "SELECT date_trunc('day', m.SentOn) AS Bucket, COUNT(DISTINCT m.MessageId) AS Total FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.UserId = m.SendingUserId WHERE (%ALLDEPTS% = 1 OR dm.DepartmentId = %DID%) AND m.IsDeleted = false AND m.SentOn >= %STARTDATE% AND m.SentOn <= %ENDDATE% GROUP BY date_trunc('day', m.SentOn)"; + SelectReportMessagesByMonthQuery = + "SELECT date_trunc('month', m.SentOn) AS Bucket, COUNT(DISTINCT m.MessageId) AS Total FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.UserId = m.SendingUserId WHERE (%ALLDEPTS% = 1 OR dm.DepartmentId = %DID%) AND m.IsDeleted = false AND m.SentOn >= %STARTDATE% AND m.SentOn <= %ENDDATE% GROUP BY date_trunc('month', m.SentOn)"; + SelectReportDepartmentsTotalQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME%"; + SelectReportNewDepartmentsQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE CreatedOn >= %STARTDATE% AND CreatedOn <= %ENDDATE%"; UpdateUserDispatchesAsSentQuery = @" UPDATE Calls SET DispatchCount = (DispatchCount + 1), diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index c7aaf620f..fb6a4d8d9 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1129,6 +1129,52 @@ FROM [dbo].[Shifts] sh "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [IsDeleted] = 0 AND [State] = 0"; SelectAllCallsByDidLoggedOnQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND AND [LoggedOn] >= %DATE%"; + + // ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) ----- + SelectReportCallsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE%"; + SelectReportCallsCountAllTimeQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0"; + SelectReportActiveCallsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [State] = 0"; + SelectReportPersonnelCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND ([IsDisabled] IS NULL OR [IsDisabled] = 0)"; + SelectReportUnitsCountQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%)"; + SelectReportCallsByDayQuery = + "SELECT CAST([LoggedOn] AS date) AS [Bucket], COUNT(*) AS [Total] FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE% GROUP BY CAST([LoggedOn] AS date)"; + SelectReportCallsByMonthQuery = + "SELECT DATEFROMPARTS(YEAR([LoggedOn]), MONTH([LoggedOn]), 1) AS [Bucket], COUNT(*) AS [Total] FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE% GROUP BY YEAR([LoggedOn]), MONTH([LoggedOn])"; + SelectReportCallsByTypeQuery = + "SELECT [Type] AS [GroupKey], COUNT(*) AS [Total] FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE% GROUP BY [Type]"; + SelectReportCallsByPriorityQuery = + "SELECT [Priority] AS [GroupKey], COUNT(*) AS [Total] FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE% GROUP BY [Priority]"; + SelectReportCallsByStateQuery = + "SELECT [State] AS [GroupKey], COUNT(*) AS [Total] FROM %SCHEMA%.%TABLENAME% WHERE (%ALLDEPTS% = 1 OR [DepartmentId] = %DID%) AND [IsDeleted] = 0 AND [LoggedOn] >= %STARTDATE% AND [LoggedOn] <= %ENDDATE% GROUP BY [State]"; + // Latest-state-per-entity counts grouped by raw status id (built-in enum OR CustomStateDetailId). + SelectReportLatestPersonnelStatesQuery = + "SELECT latest.[ActionTypeId] AS [GroupKey], COUNT(*) AS [Total] FROM (SELECT al.[ActionTypeId], ROW_NUMBER() OVER (PARTITION BY al.[UserId] ORDER BY al.[Timestamp] DESC) AS rn FROM %SCHEMA%.%TABLENAME% al WHERE (%ALLDEPTS% = 1 OR al.[DepartmentId] = %DID%)) latest WHERE latest.rn = 1 GROUP BY latest.[ActionTypeId]"; + SelectReportLatestUnitStatesQuery = + "SELECT latest.[State] AS [GroupKey], COUNT(*) AS [Total] FROM (SELECT us.[State], ROW_NUMBER() OVER (PARTITION BY us.[UnitId] ORDER BY us.[Timestamp] DESC) AS rn FROM %SCHEMA%.%TABLENAME% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.[UnitId] = us.[UnitId] WHERE (%ALLDEPTS% = 1 OR u.[DepartmentId] = %DID%)) latest WHERE latest.rn = 1 GROUP BY latest.[State]"; + ReportingDailyRollupTable = "ReportingDailyRollup"; + SelectReportRollupsQuery = + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [BucketDateUtc] >= %STARTDATE% AND [BucketDateUtc] <= %ENDDATE% AND [Metric] = %METRIC% AND ((%HASDEPT% = 0 AND [DepartmentId] IS NULL) OR (%HASDEPT% = 1 AND [DepartmentId] = %DID%))"; + DeleteReportRollupForDateQuery = + "DELETE FROM %SCHEMA%.%TABLENAME% WHERE [BucketDateUtc] = %BUCKETDATE% AND ((%HASDEPT% = 0 AND [DepartmentId] IS NULL) OR (%HASDEPT% = 1 AND [DepartmentId] = %DID%))"; + InsertReportRollupQuery = + "INSERT INTO %SCHEMA%.%TABLENAME% ([DepartmentId],[BucketDateUtc],[Metric],[Dimension],[ItemCount],[SumValue],[MinValue],[MaxValue],[P50],[P90],[CreatedOnUtc]) VALUES (@DepartmentId,@BucketDateUtc,@Metric,@Dimension,@ItemCount,@SumValue,@MinValue,@MaxValue,@P50,@P90,@CreatedOnUtc)"; + // Messages have no DepartmentId; scope via a LEFT JOIN to DepartmentMembers on the sender, and + // COUNT(DISTINCT MessageId) so system-wide (%ALLDEPTS%=1) counts each message once. + SelectReportMessagesCountQuery = + "SELECT COUNT(DISTINCT m.[MessageId]) FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.[UserId] = m.[SendingUserId] WHERE (%ALLDEPTS% = 1 OR dm.[DepartmentId] = %DID%) AND m.[IsDeleted] = 0 AND m.[SentOn] >= %STARTDATE% AND m.[SentOn] <= %ENDDATE%"; + SelectReportMessagesByDayQuery = + "SELECT CAST(m.[SentOn] AS date) AS [Bucket], COUNT(DISTINCT m.[MessageId]) AS [Total] FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.[UserId] = m.[SendingUserId] WHERE (%ALLDEPTS% = 1 OR dm.[DepartmentId] = %DID%) AND m.[IsDeleted] = 0 AND m.[SentOn] >= %STARTDATE% AND m.[SentOn] <= %ENDDATE% GROUP BY CAST(m.[SentOn] AS date)"; + SelectReportMessagesByMonthQuery = + "SELECT DATEFROMPARTS(YEAR(m.[SentOn]), MONTH(m.[SentOn]), 1) AS [Bucket], COUNT(DISTINCT m.[MessageId]) AS [Total] FROM %SCHEMA%.%TABLENAME% m LEFT JOIN %SCHEMA%.%MEMBERSTABLE% dm ON dm.[UserId] = m.[SendingUserId] WHERE (%ALLDEPTS% = 1 OR dm.[DepartmentId] = %DID%) AND m.[IsDeleted] = 0 AND m.[SentOn] >= %STARTDATE% AND m.[SentOn] <= %ENDDATE% GROUP BY YEAR(m.[SentOn]), MONTH(m.[SentOn])"; + SelectReportDepartmentsTotalQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME%"; + SelectReportNewDepartmentsQuery = + "SELECT COUNT(*) FROM %SCHEMA%.%TABLENAME% WHERE [CreatedOn] >= %STARTDATE% AND [CreatedOn] <= %ENDDATE%"; UpdateUserDispatchesAsSentQuery = @" UPDATE Calls SET DispatchCount = (DispatchCount + 1), diff --git a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs index 4aca22109..9c2fd2725 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SystemAuditRepository.cs @@ -39,8 +39,10 @@ public async Task> GetByUserIdPagedAsync(string userId, dynamicParameters.Add("UserId", userId); dynamicParameters.Add("StartDate", startDate); dynamicParameters.Add("EndDate", endDate); - dynamicParameters.Add("Offset", (page - 1) * pageSize); - dynamicParameters.Add("PageSize", pageSize); + var safePage = page < 1 ? 1 : page; + var safePageSize = pageSize < 1 ? 1 : pageSize; + dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + dynamicParameters.Add("PageSize", safePageSize); var query = _queryFactory.GetQuery(); @@ -84,8 +86,10 @@ public async Task> GetByDepartmentIdPagedAsync(int depa dynamicParameters.Add("DepartmentId", departmentId); dynamicParameters.Add("StartDate", startDate); dynamicParameters.Add("EndDate", endDate); - dynamicParameters.Add("Offset", (page - 1) * pageSize); - dynamicParameters.Add("PageSize", pageSize); + var safePage = page < 1 ? 1 : page; + var safePageSize = pageSize < 1 ? 1 : pageSize; + dynamicParameters.Add("Offset", (safePage - 1) * safePageSize); + dynamicParameters.Add("PageSize", safePageSize); var query = _queryFactory.GetQuery(); diff --git a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs new file mode 100644 index 000000000..60e2d4ce6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Reporting; +using Resgrid.Services.Reporting; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class IncidentExportTests + { + private static Call SampleCall() => new Call + { + CallId = 7, + Number = "26-1", + Type = "Structure Fire", + Priority = 1, + State = 0, + Name = "Test Call", + NatureOfCall = "Fire, large", + Address = "123 Main St", + LoggedOn = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc), + DispatchOn = new DateTime(2026, 6, 1, 12, 0, 45, DateTimeKind.Utc) + }; + + [Test] + public void Generic_profile_has_no_required_gaps() + { + IncidentExport.GetUnmappedRequiredFields(ExportProfile.Generic).Should().BeEmpty(); + } + + [Test] + public void Nfirs_profile_reports_known_gaps() + { + var gaps = IncidentExport.GetUnmappedRequiredFields(ExportProfile.Nfirs); + + gaps.Should().Contain("FDID"); + gaps.Should().Contain("IncidentTypeCode"); + } + + [Test] + public void Nemsis_profile_reports_known_gaps() + { + var gaps = IncidentExport.GetUnmappedRequiredFields(ExportProfile.Nemsis); + + gaps.Should().Contain("PatientCareReportNumber"); + gaps.Should().Contain("EMSAgencyNumber"); + } + + [Test] + public void BuildCsv_writes_header_and_escapes_values() + { + var bytes = IncidentExport.BuildCsv(ExportProfile.Generic, new[] { SampleCall() }); + var csv = Encoding.UTF8.GetString(bytes); + + csv.Should().StartWith("CallId,Number,IncidentNumber,Type"); + // A value containing a comma must be quoted. + csv.Should().Contain("\"Fire, large\""); + // Mapped UTC timestamp present. + csv.Should().Contain("2026-06-01T12:00:00Z"); + } + + [Test] + public void BuildCsv_nfirs_emits_full_schema_header_with_empty_gap_cells() + { + var bytes = IncidentExport.BuildCsv(ExportProfile.Nfirs, new[] { SampleCall() }); + var csv = Encoding.UTF8.GetString(bytes); + + // Gap columns are present in the schema (header) even though Resgrid can't fill them. + csv.Should().Contain("FDID"); + csv.Should().Contain("IncidentTypeCode"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReportingAnalyticsTests.cs b/Tests/Resgrid.Tests/Services/ReportingAnalyticsTests.cs new file mode 100644 index 000000000..80253fe44 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReportingAnalyticsTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Reporting; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ReportingAnalyticsTests + { + [Test] + public void Summarize_returns_zeros_for_empty_input() + { + var summary = ReportingMath.Summarize(new List()); + + summary.Count.Should().Be(0); + summary.Mean.Should().Be(0d); + summary.P90.Should().Be(0d); + } + + [Test] + public void Summarize_computes_interpolated_percentiles() + { + var samples = new List { 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 }; + + var summary = ReportingMath.Summarize(samples); + + summary.Count.Should().Be(10); + summary.Min.Should().Be(10d); + summary.Max.Should().Be(100d); + summary.Mean.Should().BeApproximately(55d, 0.0001); + summary.P50.Should().BeApproximately(55d, 0.0001); // rank 4.5 -> 50 + (60-50)*0.5 + summary.P90.Should().BeApproximately(91d, 0.0001); // rank 8.1 -> 90 + (100-90)*0.1 + } + + [Test] + public void Summarize_handles_single_sample() + { + var summary = ReportingMath.Summarize(new List { 42 }); + + summary.Count.Should().Be(1); + summary.P50.Should().Be(42d); + summary.P90.Should().Be(42d); + summary.Mean.Should().Be(42d); + } + + [Test] + public void UtilizationSeconds_computes_committed_vs_total() + { + var baseTime = new DateTime(2026, 6, 1, 9, 0, 0, DateTimeKind.Utc); + var states = new List<(DateTime, bool)> + { + (baseTime, false), // 09:00 available + (baseTime.AddHours(1), true), // 10:00 committed + (baseTime.AddHours(2), false), // 11:00 available + }; + var windowEnd = baseTime.AddHours(3); // 12:00 + + var (committed, total) = ReportingMath.UtilizationSeconds(states, windowEnd); + + committed.Should().BeApproximately(3600d, 0.0001); // 10:00 -> 11:00 + total.Should().BeApproximately(10800d, 0.0001); // 09:00 -> 12:00 + } + + [Test] + public void UtilizationSeconds_handles_empty_and_single_state() + { + var (c0, t0) = ReportingMath.UtilizationSeconds(new List<(DateTime, bool)>(), DateTime.UtcNow); + c0.Should().Be(0d); + t0.Should().Be(0d); + + var start = new DateTime(2026, 6, 1, 8, 0, 0, DateTimeKind.Utc); + var (c1, t1) = ReportingMath.UtilizationSeconds(new List<(DateTime, bool)> { (start, true) }, start.AddHours(2)); + c1.Should().BeApproximately(7200d, 0.0001); + t1.Should().BeApproximately(7200d, 0.0001); + } + + [Test] + public void AvailabilityMatrix_maps_personnel_base_types() + { + AvailabilityMatrix.ForPersonnelBaseType((int)ActionBaseTypes.Available).Should().Be(AvailabilityClass.Available); + AvailabilityMatrix.ForPersonnelBaseType((int)ActionBaseTypes.Responding).Should().Be(AvailabilityClass.Committed); + AvailabilityMatrix.ForPersonnelBaseType((int)ActionBaseTypes.Unavailable).Should().Be(AvailabilityClass.Unavailable); + AvailabilityMatrix.ForPersonnelBaseType((int)ActionBaseTypes.None).Should().Be(AvailabilityClass.Unknown); + } + + [Test] + public void AvailabilityMatrix_maps_builtin_personnel_action_types() + { + AvailabilityMatrix.ForBuiltInPersonnelActionType((int)ActionTypes.StandingBy).Should().Be(AvailabilityClass.Available); + AvailabilityMatrix.ForBuiltInPersonnelActionType((int)ActionTypes.Responding).Should().Be(AvailabilityClass.Committed); + AvailabilityMatrix.ForBuiltInPersonnelActionType((int)ActionTypes.NotResponding).Should().Be(AvailabilityClass.Unavailable); + } + + [Test] + public void AvailabilityMatrix_maps_unit_state_types() + { + AvailabilityMatrix.ForUnitStateType((int)UnitStateTypes.Available).Should().Be(AvailabilityClass.Available); + AvailabilityMatrix.ForUnitStateType((int)UnitStateTypes.Committed).Should().Be(AvailabilityClass.Committed); + AvailabilityMatrix.ForUnitStateType((int)UnitStateTypes.Delayed).Should().Be(AvailabilityClass.Delayed); + AvailabilityMatrix.ForUnitStateType((int)UnitStateTypes.OutOfService).Should().Be(AvailabilityClass.Unavailable); + } + + [Test] + public void AvailabilityMatrix_returns_unknown_for_unmapped_value() + { + AvailabilityMatrix.ForUnitStateType(9999).Should().Be(AvailabilityClass.Unknown); + AvailabilityMatrix.ForBuiltInPersonnelActionType(9999).Should().Be(AvailabilityClass.Unknown); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactVerificationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactVerificationController.cs index 943c1ce3f..34f7812ce 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ContactVerificationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactVerificationController.cs @@ -89,8 +89,19 @@ public async Task> ConfirmVerificati return BadRequest(); // Use the X-Forwarded-For aware helper so the audit log records the real client IP - // rather than the reverse-proxy / load-balancer address. - string ipAddress = IpAddressHelper.GetRequestIP(Request, true); + // rather than the reverse-proxy / load-balancer address. IP resolution can throw when no + // address is resolvable; fall back to empty so verification still proceeds (the IP is only + // recorded for auditing). + string ipAddress; + try + { + ipAddress = IpAddressHelper.GetRequestIP(Request, true); + } + catch (System.Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "ContactVerification.ConfirmVerificationCode: unable to resolve client IP; continuing without it."); + ipAddress = string.Empty; + } bool confirmed = await _contactVerificationService.ConfirmVerificationCodeAsync( UserId, DepartmentId, model.Type, model.Code, ipAddress, cancellationToken); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs new file mode 100644 index 000000000..abb2ef383 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs @@ -0,0 +1,164 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Reporting; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Reporting; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Reporting and analytics for the caller's department: a composite dashboard, realtime personnel/ + /// unit availability, response-time (NFPA), utilization and participation analytics, and incident + /// CSV export. + /// + /// SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is + /// deliberately no departmentId parameter, so a client can never request another department's data. + /// System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available + /// only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it + /// with departmentId = null. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class ReportingController : V4AuthenticatedApiControllerbase + { + private const int MaxDayWindow = 366; + private const int MaxMonthWindowDays = 366 * 5; + + private readonly IPlatformReportingService _reportingService; + + public ReportingController(IPlatformReportingService reportingService) + { + _reportingService = reportingService; + } + + /// + /// Composite dashboard for the caller's department: scalar totals, dense (zero-filled, UTC) + /// time series, top-N breakdowns, and realtime personnel/unit availability. + /// + /// Window start (UTC). + /// Window end (UTC). + /// Series bucketing: 0 = day, 1 = month. + /// Max slices per breakdown before an "Other" bucket. + [HttpGet("GetDashboard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> GetDashboard(DateTime from, DateTime to, + int granularity = 0, int topN = 5, CancellationToken cancellationToken = default) + { + var gran = granularity == 1 ? ReportGranularity.Month : ReportGranularity.Day; + var (startUtc, endUtc) = NormalizeWindow(from, to, gran == ReportGranularity.Month ? MaxMonthWindowDays : MaxDayWindow); + + var report = await _reportingService.GetDashboardReportAsync(DepartmentId, startUtc, endUtc, gran, topN, false, cancellationToken); + report.TimeZone = TimeZone; + + var result = new DashboardReportResult { Data = report, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Response-time / NFPA analytics (alarm handling, turnout, travel, total response). + [HttpGet("GetResponseTimes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> GetResponseTimes(DateTime from, DateTime to, + CancellationToken cancellationToken = default) + { + var (startUtc, endUtc) = NormalizeWindow(from, to, MaxMonthWindowDays); + + var report = await _reportingService.GetResponseTimeReportAsync(DepartmentId, startUtc, endUtc, false, cancellationToken); + + var result = new ResponseTimeReportResult { Data = report, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Unit Hour Utilization and workload analytics. + [HttpGet("GetUtilization")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> GetUtilization(DateTime from, DateTime to, + CancellationToken cancellationToken = default) + { + var (startUtc, endUtc) = NormalizeWindow(from, to, MaxMonthWindowDays); + + var report = await _reportingService.GetUtilizationReportAsync(DepartmentId, startUtc, endUtc, false, cancellationToken); + + var result = new UtilizationReportResult { Data = report, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Personnel participation and certification-compliance analytics. + [HttpGet("GetParticipation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> GetParticipation(DateTime from, DateTime to, + CancellationToken cancellationToken = default) + { + var (startUtc, endUtc) = NormalizeWindow(from, to, MaxMonthWindowDays); + + var report = await _reportingService.GetParticipationReportAsync(DepartmentId, startUtc, endUtc, false, cancellationToken); + + var result = new ParticipationReportResult { Data = report, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Streams a CSV export of the caller's department incidents for the window using the requested + /// field mapping (0 = Generic, 1 = NFIRS, 2 = NEMSIS). + /// + [HttpGet("ExportIncidents")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task ExportIncidents(DateTime from, DateTime to, int profile = 0, + CancellationToken cancellationToken = default) + { + var (startUtc, endUtc) = NormalizeWindow(from, to, MaxMonthWindowDays); + var exportProfile = Enum.IsDefined(typeof(ExportProfile), profile) ? (ExportProfile)profile : ExportProfile.Generic; + + var stream = await _reportingService.ExportIncidentsCsvAsync(DepartmentId, startUtc, endUtc, exportProfile, cancellationToken); + var fileName = $"incidents_{DepartmentId}_{startUtc:yyyyMMdd}_{endUtc:yyyyMMdd}_{exportProfile}.csv"; + return File(stream, "text/csv", fileName); + } + + /// + /// Returns the standardized required fields the given export profile cannot fill from Resgrid data + /// (the gap report). 0 = Generic, 1 = NFIRS, 2 = NEMSIS. + /// + [HttpGet("GetExportGaps")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public ActionResult GetExportGaps(int profile = 0) + { + var exportProfile = Enum.IsDefined(typeof(ExportProfile), profile) ? (ExportProfile)profile : ExportProfile.Generic; + var gaps = _reportingService.GetUnmappedRequiredExportFields(exportProfile); + + var result = new ExportGapReportResult { PageSize = gaps.Count, Status = ResponseHelper.Success }; + result.Data.AddRange(gaps); + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + // Normalizes the window to UTC, corrects a reversed range, and clamps the span to bound query cost. + private static (DateTime startUtc, DateTime endUtc) NormalizeWindow(DateTime from, DateTime to, int maxDays) + { + var start = ToUtc(from); + var end = ToUtc(to); + if (start > end) + (start, end) = (end, start); + if ((end - start).TotalDays > maxDays) + start = end.AddDays(-maxDays); + return (start, end); + } + + private static DateTime ToUtc(DateTime value) + => value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(value, DateTimeKind.Utc) : value.ToUniversalTime(); + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Reporting/ReportingResults.cs b/Web/Resgrid.Web.Services/Models/v4/Reporting/ReportingResults.cs new file mode 100644 index 000000000..f26ae8847 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Reporting/ReportingResults.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Resgrid.Model.Reporting; + +namespace Resgrid.Web.Services.Models.v4.Reporting +{ + /// Composite dashboard report (scalar totals, dense series, breakdowns). + public class DashboardReportResult : StandardApiResponseV4Base + { + /// Response Data + public DashboardReport Data { get; set; } + } + + /// Response-time / NFPA analytics report. + public class ResponseTimeReportResult : StandardApiResponseV4Base + { + /// Response Data + public ResponseTimeReport Data { get; set; } + } + + /// Unit Hour Utilization analytics report. + public class UtilizationReportResult : StandardApiResponseV4Base + { + /// Response Data + public UtilizationReport Data { get; set; } + } + + /// Personnel participation analytics report. + public class ParticipationReportResult : StandardApiResponseV4Base + { + /// Response Data + public ParticipationReport Data { get; set; } + } + + /// Lists the standardized required export fields Resgrid does not capture (the gap report). + public class ExportGapReportResult : StandardApiResponseV4Base + { + /// Response Data + public List Data { get; set; } = new List(); + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 5a68f719b..caca028f0 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1333,6 +1333,50 @@ ID of the protocol attachment + + + Reporting and analytics for the caller's department: a composite dashboard, realtime personnel/ + unit availability, response-time (NFPA), utilization and participation analytics, and incident + CSV export. + + SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is + deliberately no departmentId parameter, so a client can never request another department's data. + System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available + only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it + with departmentId = null. + + + + + Composite dashboard for the caller's department: scalar totals, dense (zero-filled, UTC) + time series, top-N breakdowns, and realtime personnel/unit availability. + + Window start (UTC). + Window end (UTC). + Series bucketing: 0 = day, 1 = month. + Max slices per breakdown before an "Other" bucket. + + + Response-time / NFPA analytics (alarm handling, turnout, travel, total response). + + + Unit Hour Utilization and workload analytics. + + + Personnel participation and certification-compliance analytics. + + + + Streams a CSV export of the caller's department incidents for the window using the requested + field mapping (0 = Generic, 1 = NFIRS, 2 = NEMSIS). + + + + + Returns the standardized required fields the given export profile cannot fill from Resgrid data + (the gap report). 0 = Generic, 1 = NFIRS, 2 = NEMSIS. + + User generated forms that are dispayed to get custom information for New Calls, Unit Checks, etc @@ -8415,6 +8459,36 @@ Response Data + + Composite dashboard report (scalar totals, dense series, breakdowns). + + + Response Data + + + Response-time / NFPA analytics report. + + + Response Data + + + Unit Hour Utilization analytics report. + + + Response Data + + + Personnel participation analytics report. + + + Response Data + + + Lists the standardized required export fields Resgrid does not capture (the gap report). + + + Response Data + A role in the Resgrid system diff --git a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json index 6d420cd7f..5673a18e3 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json +++ b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json @@ -33,7 +33,6 @@ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -102,15 +101,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -120,7 +117,6 @@ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", "dev": true, - "peer": true, "dependencies": { "@babel/types": "^7.23.6", "@jridgewell/gen-mapping": "^0.3.2", @@ -878,6 +874,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1049,6 +1046,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1318,7 +1316,6 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true, - "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -1606,6 +1603,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1646,6 +1644,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1884,6 +1883,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/Workers/Resgrid.Workers.Console/Commands/ReportingRollupCommand.cs b/Workers/Resgrid.Workers.Console/Commands/ReportingRollupCommand.cs new file mode 100644 index 000000000..709ff9489 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/ReportingRollupCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public class ReportingRollupCommand : IQuidjiboCommand + { + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + + public ReportingRollupCommand(int id) + { + Id = id; + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 54d20f3a1..4b8d8e8a0 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -420,6 +420,12 @@ await Client.ScheduleAsync("Weather Alert Import", new Commands.WeatherAlertImportCommand(20), Cron.MinuteIntervals(5), stoppingToken); + + _logger.Log(LogLevel.Information, "Scheduling Reporting Rollup"); + await Client.ScheduleAsync("Reporting Rollup", + new Commands.ReportingRollupCommand(21), + Cron.Daily(3, 30), + stoppingToken); } else { diff --git a/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs new file mode 100644 index 000000000..4e17fcf76 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs @@ -0,0 +1,57 @@ +using Autofac; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Model.Services; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Workers.Console.Tasks +{ + /// + /// Nightly job that computes the previous UTC day's reporting rollups (call volume, call-processing + /// time, unit hour utilization) for every department plus a system-wide aggregate, into the + /// ReportingDailyRollup store. Backed by . + /// + public class ReportingRollupTask : IQuidjiboHandler + { + public string Name => "Reporting Rollup"; + public int Priority => 1; + private readonly ILogger _logger; + + public ReportingRollupTask(ILogger logger) + { + _logger = logger; + } + + public async Task ProcessAsync(ReportingRollupCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + try + { + progress.Report(1, $"Starting the {Name} Task"); + + var departmentsService = Bootstrapper.GetKernel().Resolve(); + var rollupProcessor = Bootstrapper.GetKernel().Resolve(); + + var departments = await departmentsService.GetAllAsync(); + var departmentIds = departments?.Select(d => d.DepartmentId).ToList() ?? new List(); + + // Roll up the previous full UTC day. + var dayUtc = DateTime.UtcNow.Date.AddDays(-1); + var written = await rollupProcessor.RunDailyRollupForAllAsync(dayUtc, departmentIds, cancellationToken); + + progress.Report(100, $"Finished {Name}: wrote {written} rollup rows for {departmentIds.Count} departments"); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + _logger.LogError(ex.ToString()); + } + } + } +}