diff --git a/BinDays.Api.Collectors/Collectors/Councils/BassetlawDistrictCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/BassetlawDistrictCouncil.cs new file mode 100644 index 00000000..987757eb --- /dev/null +++ b/BinDays.Api.Collectors/Collectors/Councils/BassetlawDistrictCouncil.cs @@ -0,0 +1,349 @@ +namespace BinDays.Api.Collectors.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors.Vendors; +using BinDays.Api.Collectors.Models; +using BinDays.Api.Collectors.Utilities; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +/// +/// Collector implementation for Bassetlaw District Council. +/// +internal sealed class BassetlawDistrictCouncil : GovUkCollectorBase, ICollector +{ + /// + public string Name => "Bassetlaw District Council"; + + /// + public Uri WebsiteUrl => new("https://www.bassetlaw.gov.uk/"); + + /// + public override string GovUkId => "bassetlaw"; + + /// + /// The list of bin types for this collector. + /// + private readonly IReadOnlyCollection _binTypes = + [ + new() + { + Name = "General Waste", + Colour = BinColour.Green, + Keys = [ "Green" ], + }, + new() + { + Name = "Recycling", + Colour = BinColour.Blue, + Keys = [ "Blue" ], + }, + ]; + + /// + /// The order of layers to query for bin data. + /// + private readonly int[] _layerOrder = [0, 1, 22, 3, 4]; + + /// + /// Base dates for the recycling and general waste rotations by layer id. + /// + private readonly Dictionary _baseDates = new() + { + { 0, (new DateOnly(2022, 1, 31), new DateOnly(2018, 1, 1)) }, + { 1, (new DateOnly(2022, 1, 25), new DateOnly(2022, 2, 1)) }, + { 3, (new DateOnly(2022, 2, 3), new DateOnly(2018, 1, 1)) }, + { 4, (new DateOnly(2022, 2, 4), new DateOnly(2022, 1, 28)) }, + { 22, (new DateOnly(2022, 2, 2), new DateOnly(2022, 1, 26)) }, + }; + + /// + public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse) + { + // Prepare client-side request for getting addresses + if (clientSideResponse == null) + { + var clientSideRequest = new ClientSideRequest + { + RequestId = 1, + Url = $"https://utility.arcgis.com/usrsvcs/servers/6fc1ccfdd03249299be8216a9f596935/rest/services/OSPlacesCascade_UPRN_basic/GeocodeServer/findAddressCandidates?f=json&SingleLine={postcode}", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + }; + + var getAddressesResponse = new GetAddressesResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getAddressesResponse; + } + // Process addresses from response + else if (clientSideResponse.RequestId == 1) + { + var responseJson = JsonSerializer.Deserialize(clientSideResponse.Content)!; + var rawAddresses = responseJson["candidates"]!.AsArray()!; + + // Iterate through each address, and create a new address object + var addresses = new List
(); + foreach (var rawAddress in rawAddresses) + { + var attributes = rawAddress!["attributes"]!.AsObject(); + var location = rawAddress["location"]!.AsObject(); + + var uprn = attributes["UPRN"]!.GetValue().Trim(); + var property = attributes["ADDRESS"]!.GetValue().Trim(); + + var x = location["x"]!.GetValue().ToString(CultureInfo.InvariantCulture); + var y = location["y"]!.GetValue().ToString(CultureInfo.InvariantCulture); + + // Uid format: "uprn;x-coordinate;y-coordinate" + var address = new Address + { + Property = property, + Postcode = postcode, + Uid = $"{uprn};{x};{y}", + }; + + addresses.Add(address); + } + + var getAddressesResponse = new GetAddressesResponse + { + Addresses = [.. addresses], + }; + + return getAddressesResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } + + /// + public GetBinDaysResponse GetBinDays(Address address, ClientSideResponse? clientSideResponse) + { + // Prepare client-side request for getting bin polygons + if (clientSideResponse == null) + { + // Uid format: "uprn;x-coordinate;y-coordinate" + var coordinateParts = address.Uid!.Split(';'); + var x = coordinateParts[1]; + var y = coordinateParts[2]; + + var clientSideRequest = CreateLayerRequest(1, 0, x, y); + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Process layer responses + else if (clientSideResponse.RequestId is >= 1 and <= 5) + { + var layerPosition = int.Parse(clientSideResponse.Options.Metadata["layerPosition"], CultureInfo.InvariantCulture); + var x = clientSideResponse.Options.Metadata["x"]; + var y = clientSideResponse.Options.Metadata["y"]; + + var responseJson = JsonSerializer.Deserialize(clientSideResponse.Content)!; + var features = responseJson["features"]!.AsArray()!; + + if (features.Count == 0) + { + var nextLayerPosition = layerPosition + 1; + + if (nextLayerPosition >= _layerOrder.Length) + { + throw new InvalidOperationException("No bin data found for address."); + } + + var nextClientSideRequest = CreateLayerRequest(clientSideResponse.RequestId + 1, nextLayerPosition, x, y); + + var nextLayerResponse = new GetBinDaysResponse + { + NextClientSideRequest = nextClientSideRequest, + }; + + return nextLayerResponse; + } + + var layerId = _layerOrder[layerPosition]; + var attributes = features[0]!["attributes"]!.AsObject(); + + var collectionDay = GetCollectionDay(attributes); + + var (Blue, Green) = _baseDates[layerId]; + var blueDates = BuildUpcomingDates(Blue, collectionDay); + var greenDates = BuildUpcomingDates(Green, collectionDay); + + var binDays = new List(); + + // Iterate through each general waste date, and create a new bin day object + foreach (var date in greenDates) + { + var binDay = new BinDay + { + Date = date, + Address = address, + Bins = ProcessingUtilities.GetMatchingBins(_binTypes, "Green"), + }; + + binDays.Add(binDay); + } + + // Iterate through each recycling date, and create a new bin day object + foreach (var date in blueDates) + { + var binDay = new BinDay + { + Date = date, + Address = address, + Bins = ProcessingUtilities.GetMatchingBins(_binTypes, "Blue"), + }; + + binDays.Add(binDay); + } + + var getBinDaysResponse = new GetBinDaysResponse + { + BinDays = ProcessingUtilities.ProcessBinDays(binDays), + }; + + return getBinDaysResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } + + /// + /// Creates a client-side request for a specific feature layer. + /// + private ClientSideRequest CreateLayerRequest(int requestId, int layerPosition, string x, string y) + { + var layerId = _layerOrder[layerPosition]; + + var clientSideRequest = new ClientSideRequest + { + RequestId = requestId, + Url = $"https://services1.arcgis.com/P2LV4qXI9z8W2RdA/arcgis/rest/services/Bassetlaw_District_Bin_Collection_WFL1/FeatureServer/{layerId}/query?f=json&geometry={x},{y}&geometryType=esriGeometryPoint&inSR=27700&spatialRel=esriSpatialRelIntersects&outFields=*", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = + { + { "x", x }, + { "y", y }, + { "layerPosition", layerPosition.ToString(CultureInfo.InvariantCulture) }, + }, + }, + }; + + return clientSideRequest; + } + + /// + /// Determines the collection day from the feature attributes. + /// + private static DayOfWeek GetCollectionDay(JsonObject attributes) + { + var dayFields = new (string Field, DayOfWeek Day)[] + { + ("Sunday", DayOfWeek.Sunday), + ("Monday", DayOfWeek.Monday), + ("Tuesday", DayOfWeek.Tuesday), + ("Wednesday", DayOfWeek.Wednesday), + ("Thursday", DayOfWeek.Thursday), + ("Friday", DayOfWeek.Friday), + ("Saturday", DayOfWeek.Saturday), + }; + + foreach (var (Field, Day) in dayFields) + { + if (attributes.TryGetPropertyValue(Field, out var valueNode)) + { + var value = valueNode!.GetValue().Trim(); + + if (value.Equals("Yes", StringComparison.OrdinalIgnoreCase)) + { + return Day; + } + } + } + + var dayName = attributes["Day_"]!.GetValue().Trim(); + + return Enum.Parse(dayName); + } + + /// + /// Builds a list of upcoming collection dates using the fortnightly schedule. + /// + private static IReadOnlyCollection BuildUpcomingDates(DateOnly baseDate, DayOfWeek collectionDay) + { + const int pickupWeekInterval = 2; + const int occurrences = 6; + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + var initialDate = GetNextCollectionDate( + today, + baseDate, + collectionDay, + pickupWeekInterval + ); + + var dates = new List + { + initialDate, + }; + + for (var index = 1; index < occurrences; index++) + { + dates.Add(dates[index - 1].AddDays(pickupWeekInterval * 7)); + } + + return dates; + } + + /// + /// Calculates the next collection date based on the base week and pickup interval. + /// + private static DateOnly GetNextCollectionDate( + DateOnly today, + DateOnly baseDate, + DayOfWeek collectionDay, + int pickupWeekInterval + ) + { + var todayWeekStart = today.AddDays(0 - (int)today.DayOfWeek); + var baseWeekStart = baseDate.AddDays(0 - (int)baseDate.DayOfWeek); + + var weeksBetween = (todayWeekStart.ToDateTime(TimeOnly.MinValue) - baseWeekStart.ToDateTime(TimeOnly.MinValue)).Days / 7; + var weekRemainder = ((weeksBetween % pickupWeekInterval) + pickupWeekInterval) % pickupWeekInterval; + + var daysUntilCollection = ((int)collectionDay - (int)today.DayOfWeek + 7) % 7; + + if (weekRemainder > 0) + { + daysUntilCollection += 7 * (pickupWeekInterval - weekRemainder); + } + else if (daysUntilCollection == 0) + { + daysUntilCollection += pickupWeekInterval * 7; + } + + return today.AddDays(daysUntilCollection); + } +} diff --git a/BinDays.Api.IntegrationTests/Collectors/Councils/BassetlawDistrictCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/BassetlawDistrictCouncilTests.cs new file mode 100644 index 00000000..4dda02f2 --- /dev/null +++ b/BinDays.Api.IntegrationTests/Collectors/Councils/BassetlawDistrictCouncilTests.cs @@ -0,0 +1,32 @@ +namespace BinDays.Api.IntegrationTests.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors.Councils; +using BinDays.Api.IntegrationTests.Helpers; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +public class BassetlawDistrictCouncilTests +{ + private readonly IntegrationTestClient _client; + private readonly ITestOutputHelper _outputHelper; + private static readonly string _govUkId = new BassetlawDistrictCouncil().GovUkId; + + public BassetlawDistrictCouncilTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + _client = new IntegrationTestClient(outputHelper); + } + + [Theory] + [InlineData("DN22 7EW")] + public async Task GetBinDaysTest(string postcode) + { + await TestSteps.EndToEnd( + _client, + postcode, + _govUkId, + _outputHelper + ); + } +}