Skip to content

Commit 968e0d5

Browse files
feat(api): add GET /api/orders/stuck endpoint (BD-694)
- Add OrdersController with stuck orders endpoint - Implement StuckOrderService for business logic - Add filters: statusId, status, minHours, maxHours - Add pagination: limit (default 100), offset - Register services in DI container - Add comprehensive unit tests (23 new tests) TDD: Tests written first, 63 total tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bbe9220 commit 968e0d5

File tree

6 files changed

+597
-0
lines changed

6 files changed

+597
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using OrderMonitor.Core.Interfaces;
3+
using OrderMonitor.Core.Models;
4+
5+
namespace OrderMonitor.Api.Controllers;
6+
7+
/// <summary>
8+
/// Controller for order monitoring endpoints.
9+
/// </summary>
10+
[ApiController]
11+
[Route("api/[controller]")]
12+
[Produces("application/json")]
13+
public class OrdersController : ControllerBase
14+
{
15+
private readonly IStuckOrderService _stuckOrderService;
16+
private readonly ILogger<OrdersController> _logger;
17+
18+
public OrdersController(IStuckOrderService stuckOrderService, ILogger<OrdersController> logger)
19+
{
20+
_stuckOrderService = stuckOrderService ?? throw new ArgumentNullException(nameof(stuckOrderService));
21+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
22+
}
23+
24+
/// <summary>
25+
/// Gets all orders currently stuck beyond their threshold.
26+
/// </summary>
27+
/// <param name="statusId">Filter by specific status ID</param>
28+
/// <param name="status">Filter by status name (partial match)</param>
29+
/// <param name="minHours">Minimum hours stuck</param>
30+
/// <param name="maxHours">Maximum hours stuck</param>
31+
/// <param name="limit">Maximum results to return (default: 100)</param>
32+
/// <param name="offset">Pagination offset (default: 0)</param>
33+
/// <param name="cancellationToken">Cancellation token</param>
34+
/// <returns>List of stuck orders with pagination info</returns>
35+
/// <response code="200">Returns the list of stuck orders</response>
36+
/// <response code="500">If an internal error occurs</response>
37+
[HttpGet("stuck")]
38+
[ProducesResponseType(typeof(StuckOrdersResponse), StatusCodes.Status200OK)]
39+
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
40+
public async Task<ActionResult<StuckOrdersResponse>> GetStuckOrders(
41+
[FromQuery] int? statusId = null,
42+
[FromQuery] string? status = null,
43+
[FromQuery] int? minHours = null,
44+
[FromQuery] int? maxHours = null,
45+
[FromQuery] int limit = 100,
46+
[FromQuery] int offset = 0,
47+
CancellationToken cancellationToken = default)
48+
{
49+
try
50+
{
51+
_logger.LogInformation(
52+
"Getting stuck orders with filters: StatusId={StatusId}, Status={Status}, MinHours={MinHours}, MaxHours={MaxHours}, Limit={Limit}, Offset={Offset}",
53+
statusId, status, minHours, maxHours, limit, offset);
54+
55+
var queryParams = new StuckOrderQueryParams
56+
{
57+
StatusId = statusId,
58+
Status = status,
59+
MinHours = minHours,
60+
MaxHours = maxHours,
61+
Limit = limit,
62+
Offset = offset
63+
};
64+
65+
var response = await _stuckOrderService.GetStuckOrdersAsync(queryParams, cancellationToken);
66+
67+
_logger.LogInformation("Found {Total} stuck orders", response.Total);
68+
69+
return Ok(response);
70+
}
71+
catch (Exception ex)
72+
{
73+
_logger.LogError(ex, "Error getting stuck orders");
74+
return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while retrieving stuck orders" });
75+
}
76+
}
77+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using OrderMonitor.Core.Configuration;
2+
using OrderMonitor.Core.Interfaces;
3+
using OrderMonitor.Core.Models;
4+
5+
namespace OrderMonitor.Core.Services;
6+
7+
/// <summary>
8+
/// Service for detecting and managing stuck orders.
9+
/// </summary>
10+
public class StuckOrderService : IStuckOrderService
11+
{
12+
private readonly IOrderRepository _orderRepository;
13+
14+
public StuckOrderService(IOrderRepository orderRepository)
15+
{
16+
_orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
17+
}
18+
19+
/// <inheritdoc />
20+
public async Task<StuckOrdersResponse> GetStuckOrdersAsync(
21+
StuckOrderQueryParams queryParams,
22+
CancellationToken cancellationToken = default)
23+
{
24+
var stuckOrders = await _orderRepository.GetStuckOrdersAsync(queryParams, cancellationToken);
25+
var ordersList = stuckOrders.ToList();
26+
27+
return new StuckOrdersResponse
28+
{
29+
Total = ordersList.Count,
30+
Items = ordersList,
31+
GeneratedAt = DateTime.UtcNow
32+
};
33+
}
34+
35+
/// <inheritdoc />
36+
public async Task<StuckOrdersSummary> GetStuckOrdersSummaryAsync(CancellationToken cancellationToken = default)
37+
{
38+
var totalCount = await _orderRepository.GetStuckOrdersCountAsync(cancellationToken);
39+
40+
// Get all stuck orders for grouping (with high limit for summary)
41+
var allStuckOrders = await _orderRepository.GetStuckOrdersAsync(
42+
new StuckOrderQueryParams { Limit = 10000, Offset = 0 },
43+
cancellationToken);
44+
45+
var ordersList = allStuckOrders.ToList();
46+
47+
// Group by threshold type
48+
var byThreshold = new Dictionary<string, int>
49+
{
50+
["PrepStatuses (6h)"] = ordersList.Count(o => OrderStatusConfiguration.IsPrepStatus(o.StatusId)),
51+
["FacilityStatuses (48h)"] = ordersList.Count(o => OrderStatusConfiguration.IsFacilityStatus(o.StatusId))
52+
};
53+
54+
// Group by status category
55+
var categories = OrderStatusConfiguration.GetStatusCategories();
56+
var byCategory = new Dictionary<string, int>();
57+
foreach (var category in categories)
58+
{
59+
var categoryStatusIds = category.Value.Select(s => s.StatusId).ToHashSet();
60+
var count = ordersList.Count(o => categoryStatusIds.Contains(o.StatusId));
61+
if (count > 0)
62+
{
63+
byCategory[category.Key] = count;
64+
}
65+
}
66+
67+
// Top statuses
68+
var topStatuses = ordersList
69+
.GroupBy(o => new { o.StatusId, o.Status })
70+
.Select(g => new StatusCount
71+
{
72+
StatusId = g.Key.StatusId,
73+
Status = g.Key.Status,
74+
Count = g.Count()
75+
})
76+
.OrderByDescending(s => s.Count)
77+
.Take(10)
78+
.ToList();
79+
80+
return new StuckOrdersSummary
81+
{
82+
TotalStuckOrders = totalCount,
83+
ByThreshold = byThreshold,
84+
ByStatusCategory = byCategory,
85+
TopStatuses = topStatuses,
86+
GeneratedAt = DateTime.UtcNow
87+
};
88+
}
89+
90+
/// <inheritdoc />
91+
public async Task<OrderStatusHistoryResponse> GetOrderStatusHistoryAsync(
92+
string orderId,
93+
CancellationToken cancellationToken = default)
94+
{
95+
var history = await _orderRepository.GetOrderStatusHistoryAsync(orderId, cancellationToken);
96+
97+
return new OrderStatusHistoryResponse
98+
{
99+
OrderId = orderId,
100+
History = history
101+
};
102+
}
103+
104+
/// <inheritdoc />
105+
public bool IsOrderStuck(int statusId, DateTime statusUpdatedAt)
106+
{
107+
var thresholdHours = OrderStatusConfiguration.GetThresholdHours(statusId);
108+
var hoursInStatus = (DateTime.UtcNow - statusUpdatedAt).TotalHours;
109+
return hoursInStatus > thresholdHours;
110+
}
111+
}

src/OrderMonitor.Infrastructure/DependencyInjection.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.Configuration;
22
using Microsoft.Extensions.DependencyInjection;
33
using OrderMonitor.Core.Interfaces;
4+
using OrderMonitor.Core.Services;
45
using OrderMonitor.Infrastructure.Data;
56

67
namespace OrderMonitor.Infrastructure;
@@ -26,6 +27,9 @@ public static IServiceCollection AddInfrastructure(
2627
// Register repositories
2728
services.AddScoped<IOrderRepository, OrderRepository>();
2829

30+
// Register services
31+
services.AddScoped<IStuckOrderService, StuckOrderService>();
32+
2933
return services;
3034
}
3135
}

0 commit comments

Comments
 (0)