Skip to content

Commit 14789b3

Browse files
feat: add facility breakdown, business hours, and fix duplicates
- Fix duplicate orders in email by using ROW_NUMBER() partitioning - Add FacilityCode/FacilityName fields to StuckOrderDto - Add ByFacility grouping in StuckOrdersSummary for partner-wise breakdown - Add BusinessHoursCalculator to exclude weekends and holidays - Update email template to show facility breakdown section - Enable alerts and scanner in development config - Add unit tests for BusinessHoursCalculator Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2fe77f7 commit 14789b3

8 files changed

Lines changed: 410 additions & 46 deletions

File tree

src/OrderMonitor.Api/appsettings.Development.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99

1010
"Scanner": {
11-
"Enabled": false,
11+
"Enabled": true,
1212
"IntervalMinutes": 15,
1313
"BatchSize": 100
1414
},
@@ -23,8 +23,8 @@
2323
},
2424

2525
"Alerts": {
26-
"Enabled": false,
27-
"Recipients": [],
26+
"Enabled": true,
27+
"Recipients": ["ranganathan.e@syncoms.com"],
2828
"SubjectPrefix": "[Order Monitor]"
2929
},
3030

src/OrderMonitor.Core/Models/StuckOrderDto.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ public class StuckOrderDto
1515
public int ThresholdHours { get; set; }
1616
public string? Region { get; set; }
1717
public string? CustomerEmail { get; set; }
18+
public string? FacilityCode { get; set; }
19+
public string? FacilityName { get; set; }
1820
}

src/OrderMonitor.Core/Models/StuckOrdersSummary.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public class StuckOrdersSummary
77
{
88
public int TotalStuckOrders { get; set; }
99
public Dictionary<string, int> ByThreshold { get; set; } = new();
10+
public Dictionary<string, int> ByFacility { get; set; } = new();
1011
public Dictionary<string, int> ByStatusCategory { get; set; } = new();
1112
public IEnumerable<StatusCount> TopStatuses { get; set; } = [];
1213
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
namespace OrderMonitor.Core.Services;
2+
3+
/// <summary>
4+
/// Calculates business hours excluding weekends and holidays.
5+
/// </summary>
6+
public class BusinessHoursCalculator
7+
{
8+
private readonly HashSet<DateTime> _holidays;
9+
10+
/// <summary>
11+
/// Initializes the calculator with a list of holiday dates.
12+
/// </summary>
13+
public BusinessHoursCalculator(IEnumerable<DateTime>? holidays = null)
14+
{
15+
_holidays = holidays?.Select(h => h.Date).ToHashSet() ?? GetDefaultHolidays();
16+
}
17+
18+
/// <summary>
19+
/// Calculates business hours between two dates, excluding weekends and holidays.
20+
/// </summary>
21+
/// <param name="startDate">Start date/time</param>
22+
/// <param name="endDate">End date/time (defaults to UTC now)</param>
23+
/// <returns>Number of business hours</returns>
24+
public int CalculateBusinessHours(DateTime startDate, DateTime? endDate = null)
25+
{
26+
var end = endDate ?? DateTime.UtcNow;
27+
28+
if (startDate >= end)
29+
return 0;
30+
31+
int businessHours = 0;
32+
var current = startDate;
33+
34+
while (current < end)
35+
{
36+
if (IsBusinessDay(current))
37+
{
38+
// Calculate hours for this day
39+
var dayStart = current.Date;
40+
var dayEnd = dayStart.AddDays(1);
41+
42+
var effectiveStart = current > dayStart ? current : dayStart;
43+
var effectiveEnd = end < dayEnd ? end : dayEnd;
44+
45+
if (effectiveEnd > effectiveStart)
46+
{
47+
businessHours += (int)(effectiveEnd - effectiveStart).TotalHours;
48+
}
49+
}
50+
51+
// Move to start of next day
52+
current = current.Date.AddDays(1);
53+
}
54+
55+
return businessHours;
56+
}
57+
58+
/// <summary>
59+
/// Checks if a date is a business day (not weekend, not holiday).
60+
/// </summary>
61+
public bool IsBusinessDay(DateTime date)
62+
{
63+
// Check weekend
64+
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
65+
return false;
66+
67+
// Check holiday
68+
if (_holidays.Contains(date.Date))
69+
return false;
70+
71+
return true;
72+
}
73+
74+
/// <summary>
75+
/// Gets the number of weekend days between two dates.
76+
/// </summary>
77+
public int GetWeekendDays(DateTime startDate, DateTime endDate)
78+
{
79+
int weekendDays = 0;
80+
var current = startDate.Date;
81+
82+
while (current <= endDate.Date)
83+
{
84+
if (current.DayOfWeek == DayOfWeek.Saturday || current.DayOfWeek == DayOfWeek.Sunday)
85+
weekendDays++;
86+
current = current.AddDays(1);
87+
}
88+
89+
return weekendDays;
90+
}
91+
92+
/// <summary>
93+
/// Gets the number of holidays between two dates (excluding weekends).
94+
/// </summary>
95+
public int GetHolidayDays(DateTime startDate, DateTime endDate)
96+
{
97+
return _holidays.Count(h =>
98+
h >= startDate.Date &&
99+
h <= endDate.Date &&
100+
h.DayOfWeek != DayOfWeek.Saturday &&
101+
h.DayOfWeek != DayOfWeek.Sunday);
102+
}
103+
104+
/// <summary>
105+
/// Default holidays for 2025-2026 (UK/EU focused for Printerpix).
106+
/// </summary>
107+
private static HashSet<DateTime> GetDefaultHolidays()
108+
{
109+
return new HashSet<DateTime>
110+
{
111+
// 2025 Holidays
112+
new DateTime(2025, 1, 1), // New Year's Day
113+
new DateTime(2025, 4, 18), // Good Friday
114+
new DateTime(2025, 4, 21), // Easter Monday
115+
new DateTime(2025, 5, 5), // Early May Bank Holiday
116+
new DateTime(2025, 5, 26), // Spring Bank Holiday
117+
new DateTime(2025, 8, 25), // Summer Bank Holiday
118+
new DateTime(2025, 12, 25), // Christmas Day
119+
new DateTime(2025, 12, 26), // Boxing Day
120+
121+
// 2026 Holidays
122+
new DateTime(2026, 1, 1), // New Year's Day
123+
new DateTime(2026, 4, 3), // Good Friday
124+
new DateTime(2026, 4, 6), // Easter Monday
125+
new DateTime(2026, 5, 4), // Early May Bank Holiday
126+
new DateTime(2026, 5, 25), // Spring Bank Holiday
127+
new DateTime(2026, 8, 31), // Summer Bank Holiday
128+
new DateTime(2026, 12, 25), // Christmas Day
129+
new DateTime(2026, 12, 28), // Boxing Day (observed)
130+
131+
// 2027 Holidays
132+
new DateTime(2027, 1, 1), // New Year's Day
133+
new DateTime(2027, 3, 26), // Good Friday
134+
new DateTime(2027, 3, 29), // Easter Monday
135+
new DateTime(2027, 5, 3), // Early May Bank Holiday
136+
new DateTime(2027, 5, 31), // Spring Bank Holiday
137+
new DateTime(2027, 8, 30), // Summer Bank Holiday
138+
new DateTime(2027, 12, 27), // Christmas Day (observed)
139+
new DateTime(2027, 12, 28), // Boxing Day (observed)
140+
};
141+
}
142+
}

src/OrderMonitor.Core/Services/StuckOrderService.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ namespace OrderMonitor.Core.Services;
1010
public class StuckOrderService : IStuckOrderService
1111
{
1212
private readonly IOrderRepository _orderRepository;
13+
private readonly BusinessHoursCalculator _businessHoursCalculator;
1314

1415
public StuckOrderService(IOrderRepository orderRepository)
1516
{
1617
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
18+
_businessHoursCalculator = new BusinessHoursCalculator();
1719
}
1820

1921
/// <inheritdoc />
@@ -24,6 +26,12 @@ public async Task<StuckOrdersResponse> GetStuckOrdersAsync(
2426
var stuckOrders = await _orderRepository.GetStuckOrdersAsync(queryParams, cancellationToken);
2527
var ordersList = stuckOrders.ToList();
2628

29+
// Recalculate hours using business hours (excluding weekends and holidays)
30+
foreach (var order in ordersList)
31+
{
32+
order.HoursStuck = _businessHoursCalculator.CalculateBusinessHours(order.StuckSince);
33+
}
34+
2735
return new StuckOrdersResponse
2836
{
2937
Total = ordersList.Count,
@@ -44,13 +52,26 @@ public async Task<StuckOrdersSummary> GetStuckOrdersSummaryAsync(CancellationTok
4452

4553
var ordersList = allStuckOrders.ToList();
4654

55+
// Recalculate hours using business hours (excluding weekends and holidays)
56+
foreach (var order in ordersList)
57+
{
58+
order.HoursStuck = _businessHoursCalculator.CalculateBusinessHours(order.StuckSince);
59+
}
60+
4761
// Group by threshold type
4862
var byThreshold = new Dictionary<string, int>
4963
{
5064
["PrepStatuses (6h)"] = ordersList.Count(o => OrderStatusConfiguration.IsPrepStatus(o.StatusId)),
5165
["FacilityStatuses (48h)"] = ordersList.Count(o => OrderStatusConfiguration.IsFacilityStatus(o.StatusId))
5266
};
5367

68+
// Group FacilityStatuses by Facility/Partner
69+
var byFacility = ordersList
70+
.Where(o => OrderStatusConfiguration.IsFacilityStatus(o.StatusId))
71+
.GroupBy(o => string.IsNullOrEmpty(o.FacilityName) ? "Unknown" : o.FacilityName)
72+
.OrderByDescending(g => g.Count())
73+
.ToDictionary(g => g.Key, g => g.Count());
74+
5475
// Group by status category
5576
var categories = OrderStatusConfiguration.GetStatusCategories();
5677
var byCategory = new Dictionary<string, int>();
@@ -81,6 +102,7 @@ public async Task<StuckOrdersSummary> GetStuckOrdersSummaryAsync(CancellationTok
81102
{
82103
TotalStuckOrders = totalCount,
83104
ByThreshold = byThreshold,
105+
ByFacility = byFacility,
84106
ByStatusCategory = byCategory,
85107
TopStatuses = topStatuses,
86108
GeneratedAt = DateTime.UtcNow
@@ -105,7 +127,7 @@ public async Task<OrderStatusHistoryResponse> GetOrderStatusHistoryAsync(
105127
public bool IsOrderStuck(int statusId, DateTime statusUpdatedAt)
106128
{
107129
var thresholdHours = OrderStatusConfiguration.GetThresholdHours(statusId);
108-
var hoursInStatus = (DateTime.UtcNow - statusUpdatedAt).TotalHours;
109-
return hoursInStatus > thresholdHours;
130+
var businessHours = _businessHoursCalculator.CalculateBusinessHours(statusUpdatedAt);
131+
return businessHours > thresholdHours;
110132
}
111133
}

src/OrderMonitor.Infrastructure/Data/OrderRepository.cs

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,71 +24,80 @@ public async Task<IEnumerable<StuckOrderDto>> GetStuckOrdersAsync(
2424
CancellationToken cancellationToken = default)
2525
{
2626
const string baseSql = @"
27-
SELECT
28-
co.CONumber AS OrderId,
29-
co.orderNumber AS OrderNumber,
30-
opt.Status AS StatusId,
31-
st.Tracking_Status_Name AS Status,
32-
mt.MajorProductTypeName AS ProductType,
33-
opt.lastUpdatedDate AS StuckSince,
34-
DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) AS HoursStuck,
35-
CASE
36-
WHEN opt.Status BETWEEN 3001 AND 3910 THEN 6
37-
WHEN opt.Status BETWEEN 4001 AND 5830 THEN 48
38-
ELSE 24
39-
END AS ThresholdHours,
40-
co.websiteCode AS Region,
41-
NULL AS CustomerEmail
42-
FROM ConsolidationOrder co (NOLOCK)
43-
INNER JOIN OrderProductTracking opt (NOLOCK)
44-
ON opt.CONumber = co.CONumber
45-
INNER JOIN luk_Tracking_Status st (NOLOCK)
46-
ON st.Tracking_Status_id = opt.Status
47-
INNER JOIN mas_SnSpecification sn (NOLOCK)
48-
ON sn.SnID = opt.OPT_SnSpId
49-
INNER JOIN luk_MajorProductType mt (NOLOCK)
50-
ON mt.MProductTypeID = sn.MasterProductTypeID
51-
WHERE opt.isPrimaryComponent = 1
52-
AND opt.OrderDate > DATEADD(YEAR, -2, GETUTCDATE())
53-
AND opt.Status < 6400
54-
AND (
55-
(opt.Status BETWEEN 3001 AND 3910
56-
AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 6)
57-
OR
58-
(opt.Status BETWEEN 4001 AND 5830
59-
AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 48)
60-
)";
27+
WITH StuckOrders AS (
28+
SELECT
29+
co.CONumber AS OrderId,
30+
co.orderNumber AS OrderNumber,
31+
opt.Status AS StatusId,
32+
st.Tracking_Status_Name AS Status,
33+
mt.MajorProductTypeName AS ProductType,
34+
opt.lastUpdatedDate AS StuckSince,
35+
DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) AS HoursStuck,
36+
CASE
37+
WHEN opt.Status BETWEEN 3001 AND 3910 THEN 6
38+
WHEN opt.Status BETWEEN 4001 AND 5830 THEN 48
39+
ELSE 24
40+
END AS ThresholdHours,
41+
co.websiteCode AS Region,
42+
NULL AS CustomerEmail,
43+
CAST(ISNULL(opt.FacilityId, 0) AS VARCHAR(20)) AS FacilityCode,
44+
'Facility ' + CAST(ISNULL(opt.FacilityId, 0) AS VARCHAR(10)) AS FacilityName,
45+
ROW_NUMBER() OVER (PARTITION BY co.CONumber ORDER BY opt.lastUpdatedDate DESC) AS RowNum
46+
FROM ConsolidationOrder co (NOLOCK)
47+
INNER JOIN OrderProductTracking opt (NOLOCK)
48+
ON opt.CONumber = co.CONumber
49+
INNER JOIN luk_Tracking_Status st (NOLOCK)
50+
ON st.Tracking_Status_id = opt.Status
51+
INNER JOIN mas_SnSpecification sn (NOLOCK)
52+
ON sn.SnID = opt.OPT_SnSpId
53+
INNER JOIN luk_MajorProductType mt (NOLOCK)
54+
ON mt.MProductTypeID = sn.MasterProductTypeID
55+
WHERE opt.isPrimaryComponent = 1
56+
AND opt.OrderDate > DATEADD(YEAR, -2, GETUTCDATE())
57+
AND opt.Status < 6400
58+
AND (
59+
(opt.Status BETWEEN 3001 AND 3910
60+
AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 6)
61+
OR
62+
(opt.Status BETWEEN 4001 AND 5830
63+
AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 48)
64+
)
65+
)
66+
SELECT OrderId, OrderNumber, StatusId, Status, ProductType, StuckSince,
67+
HoursStuck, ThresholdHours, Region, CustomerEmail, FacilityCode, FacilityName
68+
FROM StuckOrders
69+
WHERE RowNum = 1";
6170

6271
var sqlBuilder = new StringBuilder(baseSql);
6372
var parameters = new DynamicParameters();
6473

65-
// Apply optional filters
74+
// Apply optional filters (reference CTE columns, not original table aliases)
6675
if (queryParams.StatusId.HasValue)
6776
{
68-
sqlBuilder.Append(" AND opt.Status = @StatusId");
77+
sqlBuilder.Append(" AND StatusId = @StatusId");
6978
parameters.Add("StatusId", queryParams.StatusId.Value);
7079
}
7180

7281
if (!string.IsNullOrWhiteSpace(queryParams.Status))
7382
{
74-
sqlBuilder.Append(" AND st.Tracking_Status_Name LIKE @Status");
83+
sqlBuilder.Append(" AND Status LIKE @Status");
7584
parameters.Add("Status", $"%{queryParams.Status}%");
7685
}
7786

7887
if (queryParams.MinHours.HasValue)
7988
{
80-
sqlBuilder.Append(" AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) >= @MinHours");
89+
sqlBuilder.Append(" AND HoursStuck >= @MinHours");
8190
parameters.Add("MinHours", queryParams.MinHours.Value);
8291
}
8392

8493
if (queryParams.MaxHours.HasValue)
8594
{
86-
sqlBuilder.Append(" AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) <= @MaxHours");
95+
sqlBuilder.Append(" AND HoursStuck <= @MaxHours");
8796
parameters.Add("MaxHours", queryParams.MaxHours.Value);
8897
}
8998

9099
// Order by hours stuck descending (oldest first)
91-
sqlBuilder.Append(" ORDER BY DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) DESC");
100+
sqlBuilder.Append(" ORDER BY HoursStuck DESC");
92101

93102
// Apply pagination
94103
sqlBuilder.Append(" OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY");
@@ -104,7 +113,7 @@ AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 48)
104113
public async Task<int> GetStuckOrdersCountAsync(CancellationToken cancellationToken = default)
105114
{
106115
const string sql = @"
107-
SELECT COUNT(*)
116+
SELECT COUNT(DISTINCT co.CONumber)
108117
FROM ConsolidationOrder co (NOLOCK)
109118
INNER JOIN OrderProductTracking opt (NOLOCK)
110119
ON opt.CONumber = co.CONumber

src/OrderMonitor.Infrastructure/Services/AlertService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ private string BuildAlertEmailBody(StuckOrdersSummary summary, IEnumerable<Stuck
9191
}
9292
sb.AppendLine("</table>");
9393

94+
// By Facility (Partner-wise breakdown for FacilityStatuses)
95+
if (summary.ByFacility.Any())
96+
{
97+
sb.AppendLine("<h4>By Facility/Partner (FacilityStatuses only)</h4>");
98+
sb.AppendLine("<table style='border-collapse: collapse; width: 100%; max-width: 500px;'>");
99+
sb.AppendLine("<tr style='background-color: #f5f5f5;'><th style='padding: 8px; text-align: left; border: 1px solid #ddd;'>Facility</th><th style='padding: 8px; text-align: right; border: 1px solid #ddd;'>Count</th></tr>");
100+
foreach (var facility in summary.ByFacility)
101+
{
102+
sb.AppendLine($"<tr><td style='padding: 8px; border: 1px solid #ddd;'>{facility.Key}</td><td style='padding: 8px; text-align: right; border: 1px solid #ddd;'>{facility.Value}</td></tr>");
103+
}
104+
sb.AppendLine("</table>");
105+
}
106+
94107
// Top statuses
95108
if (summary.TopStatuses.Any())
96109
{

0 commit comments

Comments
 (0)