using FluentValidation; using Microsoft.AspNetCore.Mvc; using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Common; using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Application.Pricing.ChargeableChars.Create; using SIGCM2.Application.Pricing.ChargeableChars.Deactivate; using SIGCM2.Application.Pricing.ChargeableChars.GetById; using SIGCM2.Application.Pricing.ChargeableChars.List; using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice; namespace SIGCM2.Api.Controllers; /// /// PRC-001: Admin endpoints for ChargeableCharConfig management. /// All endpoints require 'tasacion:caracteres_especiales:gestionar'. /// Route base: api/v1/admin/chargeable-chars /// [ApiController] [Route("api/v1/admin/chargeable-chars")] public sealed class ChargeableCharConfigController : ControllerBase { private readonly IDispatcher _dispatcher; private readonly IValidator _createValidator; private readonly IValidator _scheduleValidator; public ChargeableCharConfigController( IDispatcher dispatcher, IValidator createValidator, IValidator scheduleValidator) { _dispatcher = dispatcher; _createValidator = createValidator; _scheduleValidator = scheduleValidator; } // ── GET /api/v1/admin/chargeable-chars ──────────────────────────────────── /// /// Returns a paginated list of ChargeableCharConfig rows. /// Filters: medioId (optional, long?), activeOnly (bool, default true). /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly. /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200. /// [HttpGet] [RequirePermission("tasacion:caracteres_especiales:gestionar")] [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task List( [FromQuery] long? medioId, [FromQuery] bool activeOnly = true, [FromQuery] int? page = null, [FromQuery] int? pageSize = null, [FromQuery] int? skip = null, [FromQuery] int? take = null) { // Support both page/pageSize and skip/take query patterns int resolvedPage; int resolvedPageSize; if (skip is not null || take is not null) { // Convert skip/take to page/pageSize resolvedPageSize = Math.Min(take ?? 50, 200); resolvedPage = resolvedPageSize > 0 ? ((skip ?? 0) / resolvedPageSize) + 1 : 1; } else { resolvedPage = page ?? 1; resolvedPageSize = Math.Min(pageSize ?? 20, 200); } var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize); var result = await _dispatcher.Send>(query); return Ok(result); } // ── GET /api/v1/admin/chargeable-chars/{id} ─────────────────────────────── /// /// Returns a single ChargeableCharConfig by Id. Returns 404 if not found. /// [HttpGet("{id:long}")] [RequirePermission("tasacion:caracteres_especiales:gestionar")] [ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetById([FromRoute] long id) { var result = await _dispatcher.Send( new GetChargeableCharConfigByIdQuery(id)); return result is null ? NotFound() : Ok(result); } // ── POST /api/v1/admin/chargeable-chars ─────────────────────────────────── /// /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists. /// Returns 201 Created with Location header pointing to GET /{id}. /// [HttpPost] [RequirePermission("tasacion:caracteres_especiales:gestionar")] [ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task Create([FromBody] CreateChargeableCharConfigRequest request) { var command = new CreateChargeableCharConfigCommand( request.MedioId, request.Symbol, request.Category, request.PricePerUnit, request.ValidFrom); var validation = await _createValidator.ValidateAsync(command); if (!validation.IsValid) { var errors = validation.Errors .GroupBy(e => e.PropertyName) .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); return BadRequest(new { errors }); } var result = await _dispatcher.Send(command); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } // ── PUT /api/v1/admin/chargeable-chars/{id}/price ──────────────────────── /// /// Schedules a price change for an existing ChargeableCharConfig. /// Closes the current active row and opens a new one with the new price + ValidFrom. /// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only). /// [HttpPut("{id:long}/price")] [RequirePermission("tasacion:caracteres_especiales:gestionar")] [ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task SchedulePriceChange( [FromRoute] long id, [FromBody] SchedulePriceChangeRequest request) { var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom); var validation = await _scheduleValidator.ValidateAsync(command); if (!validation.IsValid) { var errors = validation.Errors .GroupBy(e => e.PropertyName) .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); return BadRequest(new { errors }); } var result = await _dispatcher.Send(command); return Ok(result); } // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ───────────────── /// /// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR). /// Idempotent: calling on an already-inactive row is a no-op. /// [HttpPatch("{id:long}/deactivate")] [RequirePermission("tasacion:caracteres_especiales:gestionar")] [ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Deactivate([FromRoute] long id) { var result = await _dispatcher.Send( new DeactivateChargeableCharConfigCommand(id)); return Ok(result); } } // ── Request body records ────────────────────────────────────────────────────── /// PRC-001: Create ChargeableCharConfig request body. public sealed record CreateChargeableCharConfigRequest( long? MedioId, string Symbol, string Category, decimal PricePerUnit, DateOnly ValidFrom); /// PRC-001: Schedule price change request body. public sealed record SchedulePriceChangeRequest( decimal PricePerUnit, DateOnly ValidFrom);