From dda602281a4b913afe63563bc6df85a4eaf8586a Mon Sep 17 00:00:00 2001 From: Letycia Maia Date: Sun, 27 Sep 2026 20:13:34 -0300 Subject: [PATCH] =?UTF-8?q?Adicionando=20c=C3=A1lculo=20de=20emiss=C3=B5es?= =?UTF-8?q?=20dos=20voos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contracts/IEmissionsUseCase.cs | 4 +- .../Dtos/Emissions/EmissionsRequestDto.cs | 37 ++++++ .../Dtos/Emissions/EmissionsResponseDto.cs | 15 +++ .../UseCases/EmissionsUseCase.cs | 39 +++++- .../Controllers/EmissionsController.cs | 7 + Carbon.Core/Program.cs | 1 + .../Services/Emissions/EmissionsContracts.cs | 58 ++++++++- .../Services/Emissions/IEmissionsService.cs | 2 +- Carbon.Domain/Services/EmissionsService.cs | 123 ++++++++++++++++++ 9 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 Carbon.Application/Dtos/Emissions/EmissionsRequestDto.cs create mode 100644 Carbon.Application/Dtos/Emissions/EmissionsResponseDto.cs diff --git a/Carbon.Application/Contracts/IEmissionsUseCase.cs b/Carbon.Application/Contracts/IEmissionsUseCase.cs index 772b82c..4234360 100644 --- a/Carbon.Application/Contracts/IEmissionsUseCase.cs +++ b/Carbon.Application/Contracts/IEmissionsUseCase.cs @@ -1,6 +1,8 @@ +using Carbon.Application.Dtos.Emissions; + namespace Carbon.Application.Contracts; public interface IEmissionsUseCase { - + Task Calculate(EmissionsRequestDto request, CancellationToken ct); } \ No newline at end of file diff --git a/Carbon.Application/Dtos/Emissions/EmissionsRequestDto.cs b/Carbon.Application/Dtos/Emissions/EmissionsRequestDto.cs new file mode 100644 index 0000000..5832c3a --- /dev/null +++ b/Carbon.Application/Dtos/Emissions/EmissionsRequestDto.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Carbon.Domain.Contracts.Services.Emissions; + +namespace Carbon.Application.Dtos.Emissions; + +public record EmissionsRequestDto( + [Required(ErrorMessage = "Flights field is required!")] + [MinLength(1, ErrorMessage = "At least one flight must be informed!")] + List Flights, + + [property: JsonConverter(typeof(JsonStringEnumConverter))] + CabinClass Cabin = CabinClass.Economy, + + [Range(1, 100, ErrorMessage = "Passengers must be between 1 and 100!")] + int Passengers = 1, + + bool RoundTrip = false); + +public record FlightLegDto( + [Required(ErrorMessage = "Origin field is required!")] + [StringLength(3, MinimumLength = 3, ErrorMessage = "Origin must be a 3-letter IATA code!")] + string Origin, + + [Required(ErrorMessage = "Destination field is required!")] + [StringLength(3, MinimumLength = 3, ErrorMessage = "Destination must be a 3-letter IATA code!")] + string Destination, + + [Required(ErrorMessage = "CarrierCode field is required!")] + [StringLength(2, MinimumLength = 2, ErrorMessage = "CarrierCode must have 2 characters!")] + string CarrierCode, + + [Range(1, 9999, ErrorMessage = "FlightNumber must be between 1 and 9999!")] + int FlightNumber, + + [Required(ErrorMessage = "DepartureDate field is required!")] + DateOnly DepartureDate); \ No newline at end of file diff --git a/Carbon.Application/Dtos/Emissions/EmissionsResponseDto.cs b/Carbon.Application/Dtos/Emissions/EmissionsResponseDto.cs new file mode 100644 index 0000000..1b3ed66 --- /dev/null +++ b/Carbon.Application/Dtos/Emissions/EmissionsResponseDto.cs @@ -0,0 +1,15 @@ +namespace Carbon.Application.Dtos.Emissions; + +public record EmissionsResponseDto( + Guid Id, + string Cabin, + int Passengers, + bool RoundTrip, + int FlightsCount, + decimal KgPerPassenger, + decimal TotalKg, + decimal TotalTonnes, + string Level, + string ContrailsImpact, + DateOnly ModelDate, + DateTimeOffset CalculatedAt); \ No newline at end of file diff --git a/Carbon.Application/UseCases/EmissionsUseCase.cs b/Carbon.Application/UseCases/EmissionsUseCase.cs index c3dfaf0..8ce5a2e 100644 --- a/Carbon.Application/UseCases/EmissionsUseCase.cs +++ b/Carbon.Application/UseCases/EmissionsUseCase.cs @@ -1,8 +1,45 @@ using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Emissions; +using Carbon.Domain.Contracts.Services.Emissions; namespace Carbon.Application.UseCases; public class EmissionsUseCase : IEmissionsUseCase { - + private readonly IEmissionsService _emissionsService; + + public EmissionsUseCase(IEmissionsService emissionsService) + { + _emissionsService = emissionsService; + } + + public async Task Calculate(EmissionsRequestDto request, CancellationToken ct = default) + { + var calculationRequest = new EmissionsCalculationRequest( + request.Flights.Select(f => new FlightLeg( + f.Origin, + f.Destination, + f.CarrierCode, + f.FlightNumber, + f.DepartureDate)).ToList(), + request.Cabin, + request.Passengers, + request.RoundTrip); + + var result = await _emissionsService.CalculateAsync(calculationRequest, ct); + + return new EmissionsResponseDto( + result.Id, + result.Cabin.ToString(), + result.Passengers, + result.RoundTrip, + result.FlightsCount, + result.KgPerPassenger, + result.TotalKg, + result.TotalTonnes, + result.Level.ToString(), + result.ContrailsImpact.ToString(), + result.ModelDate, + result.CalculatedAt); + } } \ No newline at end of file diff --git a/Carbon.Core/Controllers/EmissionsController.cs b/Carbon.Core/Controllers/EmissionsController.cs index bd9a155..30c78f4 100644 --- a/Carbon.Core/Controllers/EmissionsController.cs +++ b/Carbon.Core/Controllers/EmissionsController.cs @@ -1,9 +1,12 @@ using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Emissions; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Carbon.Core.Controllers; [ApiController] +[Authorize] [Route("Carbon/[controller]")] public class EmissionsController : ControllerBase { @@ -13,4 +16,8 @@ public EmissionsController(IEmissionsUseCase useCase) { _useCase = useCase; } + + [HttpPost("Calculate")] + public async Task Calculate(EmissionsRequestDto request, CancellationToken ct = default) + => await _useCase.Calculate(request, ct); } \ No newline at end of file diff --git a/Carbon.Core/Program.cs b/Carbon.Core/Program.cs index 52871b1..4e0c535 100644 --- a/Carbon.Core/Program.cs +++ b/Carbon.Core/Program.cs @@ -28,6 +28,7 @@ app.UseRateLimiter(); app.UseHttpsRedirection(); app.UseAuthentication(); +app.UseAuthorization(); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Emissions/EmissionsContracts.cs b/Carbon.Domain/Contracts/Services/Emissions/EmissionsContracts.cs index d92b295..13d6166 100644 --- a/Carbon.Domain/Contracts/Services/Emissions/EmissionsContracts.cs +++ b/Carbon.Domain/Contracts/Services/Emissions/EmissionsContracts.cs @@ -1,4 +1,60 @@ +using Carbon.Domain.Contracts.Providers.TravelImpact; + namespace Carbon.Domain.Contracts.Services.Emissions { - + public enum CabinClass + { + Economy, + PremiumEconomy, + Business, + First + } + + public enum EmissionLevel + { + Low, + Moderate, + High + } + + public record FlightLeg( + string Origin, + string Destination, + string CarrierCode, + int FlightNumber, + DateOnly DepartureDate); + + public record EmissionsCalculationRequest( + List Flights, + CabinClass Cabin, + int Passengers, + bool RoundTrip); + + public record EmissionsCalculationResponse( + Guid Id, + CabinClass Cabin, + int Passengers, + bool RoundTrip, + int FlightsCount, + decimal KgPerPassenger, + decimal TotalKg, + decimal TotalTonnes, + EmissionLevel Level, + ImpactBucket ContrailsImpact, + DateOnly ModelDate, + DateTimeOffset CalculatedAt); + + /// + /// Regras de negócio do cálculo. Os limites são de referência e podem ser ajustados pelo grupo. + /// + public static class EmissionsRules + { + public const int MaxFlights = 10; + public const int MinPassengers = 1; + public const int MaxPassengers = 100; + + // Classificação por passageiro (kg de CO2e na viagem inteira) + public const decimal LowLimitKgPerPassenger = 100m; + public const decimal ModerateLimitKgPerPassenger = 500m; + } } \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Emissions/IEmissionsService.cs b/Carbon.Domain/Contracts/Services/Emissions/IEmissionsService.cs index cfb2570..560466b 100644 --- a/Carbon.Domain/Contracts/Services/Emissions/IEmissionsService.cs +++ b/Carbon.Domain/Contracts/Services/Emissions/IEmissionsService.cs @@ -2,5 +2,5 @@ namespace Carbon.Domain.Contracts.Services.Emissions; public interface IEmissionsService { - + Task CalculateAsync(EmissionsCalculationRequest request, CancellationToken ct = default); } \ No newline at end of file diff --git a/Carbon.Domain/Services/EmissionsService.cs b/Carbon.Domain/Services/EmissionsService.cs index 6983d23..5da532d 100644 --- a/Carbon.Domain/Services/EmissionsService.cs +++ b/Carbon.Domain/Services/EmissionsService.cs @@ -1,14 +1,137 @@ using Carbon.Domain.Contracts.Providers.TravelImpact; using Carbon.Domain.Contracts.Services.Emissions; +using Carbon.Domain.Exceptions; namespace Carbon.Domain.Services; public class EmissionsService : IEmissionsService { + private const decimal GramsPerKg = 1_000m; + private const decimal KgPerTonne = 1_000m; + private readonly ITravelImpactProvider _provider; public EmissionsService(ITravelImpactProvider provider) { _provider = provider; } + + public async Task CalculateAsync(EmissionsCalculationRequest request, CancellationToken ct = default) + { + Validate(request); + + var providerRequest = new ImpactProviderRequest + { + Flights = request.Flights.Select(leg => new Flight + { + Origin = leg.Origin.Trim(), + Destination = leg.Destination.Trim(), + CarrierCode = leg.CarrierCode.Trim(), + FlightNumber = leg.FlightNumber, + DepartureDate = leg.DepartureDate + }).ToList() + }; + + var providerResponse = await _provider.GetFlightEmissionsFromProviderAsync(providerRequest, ct); + + if (providerResponse.Flights.Count == 0) + { + throw new NotFoundException("No emissions data found for the informed flights. Check airports, carrier and flight number."); + } + + var gramsPerPassenger = GetGramsForCabin(providerResponse.Emissions, request.Cabin); + + if (gramsPerPassenger <= 0) + { + throw new BadRequestException($"No emissions data for cabin {request.Cabin} on the informed flights. The cabin may not exist on this aircraft."); + } + + var tripMultiplier = request.RoundTrip ? 2 : 1; + + var kgPerPassenger = gramsPerPassenger / GramsPerKg * tripMultiplier; + var totalKg = kgPerPassenger * request.Passengers; + + return new EmissionsCalculationResponse( + Id: providerResponse.Id, + Cabin: request.Cabin, + Passengers: request.Passengers, + RoundTrip: request.RoundTrip, + FlightsCount: providerResponse.Flights.Count, + KgPerPassenger: Math.Round(kgPerPassenger, 2), + TotalKg: Math.Round(totalKg, 2), + TotalTonnes: Math.Round(totalKg / KgPerTonne, 3), + Level: Classify(kgPerPassenger), + ContrailsImpact: providerResponse.ImpactBucket, + ModelDate: providerResponse.Dated, + CalculatedAt: DateTimeOffset.UtcNow); + } + + private static void Validate(EmissionsCalculationRequest request) + { + if (request.Flights is null || request.Flights.Count == 0) + { + throw new BadRequestException("At least one flight must be informed."); + } + + if (request.Flights.Count > EmissionsRules.MaxFlights) + { + throw new BadRequestException($"A maximum of {EmissionsRules.MaxFlights} flights can be calculated at once."); + } + + if (request.Passengers is < EmissionsRules.MinPassengers or > EmissionsRules.MaxPassengers) + { + throw new BadRequestException($"Passengers must be between {EmissionsRules.MinPassengers} and {EmissionsRules.MaxPassengers}."); + } + + foreach (var leg in request.Flights) + { + if (!IsIataAirport(leg.Origin) || !IsIataAirport(leg.Destination)) + { + throw new BadRequestException($"Invalid airport code on flight {leg.CarrierCode}{leg.FlightNumber}. Use the 3-letter IATA code (e.g. GRU)."); + } + + if (string.Equals(leg.Origin.Trim(), leg.Destination.Trim(), StringComparison.OrdinalIgnoreCase)) + { + throw new BadRequestException("Origin and destination must be different."); + } + + if (string.IsNullOrWhiteSpace(leg.CarrierCode) || leg.CarrierCode.Trim().Length != 2) + { + throw new BadRequestException("Carrier code must have 2 characters (e.g. LA, G3, AD)."); + } + + if (leg.FlightNumber <= 0) + { + throw new BadRequestException("Flight number must be greater than zero."); + } + + if (leg.DepartureDate == default) + { + throw new BadRequestException("Departure date is required."); + } + } + } + + private static bool IsIataAirport(string? code) + => !string.IsNullOrWhiteSpace(code) + && code.Trim().Length == 3 + && code.Trim().All(char.IsLetter); + + private static decimal GetGramsForCabin(EmissionsPerPax emissions, CabinClass cabin) + => cabin switch + { + CabinClass.Economy => emissions.Economy, + CabinClass.PremiumEconomy => emissions.PremiumEconomy, + CabinClass.Business => emissions.Business, + CabinClass.First => emissions.First, + _ => throw new BadRequestException($"Unknown cabin class: {cabin}") + }; + + private static EmissionLevel Classify(decimal kgPerPassenger) + => kgPerPassenger switch + { + < EmissionsRules.LowLimitKgPerPassenger => EmissionLevel.Low, + < EmissionsRules.ModerateLimitKgPerPassenger => EmissionLevel.Moderate, + _ => EmissionLevel.High + }; } \ No newline at end of file