2026-04-20 12:46:07 -03:00
|
|
|
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;
|
2026-04-21 10:54:47 -03:00
|
|
|
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
2026-04-20 12:46:07 -03:00
|
|
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
|
|
|
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
2026-04-21 10:54:47 -03:00
|
|
|
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
2026-04-20 12:46:07 -03:00
|
|
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.Api.Controllers;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
|
|
|
|
|
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
|
|
|
|
|
/// Route base: api/v1/admin/chargeable-chars
|
|
|
|
|
/// </summary>
|
|
|
|
|
[ApiController]
|
|
|
|
|
[Route("api/v1/admin/chargeable-chars")]
|
|
|
|
|
public sealed class ChargeableCharConfigController : ControllerBase
|
|
|
|
|
{
|
|
|
|
|
private readonly IDispatcher _dispatcher;
|
|
|
|
|
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
|
|
|
|
|
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
|
|
|
|
|
|
|
|
|
|
public ChargeableCharConfigController(
|
|
|
|
|
IDispatcher dispatcher,
|
|
|
|
|
IValidator<CreateChargeableCharConfigCommand> createValidator,
|
|
|
|
|
IValidator<SchedulePriceChangeCommand> scheduleValidator)
|
|
|
|
|
{
|
|
|
|
|
_dispatcher = dispatcher;
|
|
|
|
|
_createValidator = createValidator;
|
|
|
|
|
_scheduleValidator = scheduleValidator;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Returns a paginated list of ChargeableCharConfig rows.
|
2026-04-21 10:54:47 -03:00
|
|
|
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
|
2026-04-20 12:46:07 -03:00
|
|
|
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
|
|
|
|
|
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpGet]
|
|
|
|
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
public async Task<IActionResult> List(
|
2026-04-21 10:54:47 -03:00
|
|
|
[FromQuery] long? productTypeId,
|
2026-04-20 12:46:07 -03:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 10:54:47 -03:00
|
|
|
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
|
2026-04-20 12:46:07 -03:00
|
|
|
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[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<IActionResult> GetById([FromRoute] long id)
|
|
|
|
|
{
|
|
|
|
|
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
|
|
|
|
|
new GetChargeableCharConfigByIdQuery(id));
|
|
|
|
|
return result is null ? NotFound() : Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-04-21 10:54:47 -03:00
|
|
|
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
|
2026-04-20 12:46:07 -03:00
|
|
|
/// Returns 201 Created with Location header pointing to GET /{id}.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[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<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
|
|
|
|
{
|
|
|
|
|
var command = new CreateChargeableCharConfigCommand(
|
2026-04-21 10:54:47 -03:00
|
|
|
request.ProductTypeId,
|
2026-04-20 12:46:07 -03:00
|
|
|
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<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
|
|
|
|
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[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<IActionResult> 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<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
|
|
|
|
|
/// Idempotent: calling on an already-inactive row is a no-op.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPatch("{id:long}/deactivate")]
|
|
|
|
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
public async Task<IActionResult> Deactivate([FromRoute] long id)
|
|
|
|
|
{
|
|
|
|
|
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
|
|
|
|
|
new DeactivateChargeableCharConfigCommand(id));
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
2026-04-21 10:54:47 -03:00
|
|
|
|
|
|
|
|
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
|
|
|
|
|
/// Guard rules (enforced by SP):
|
|
|
|
|
/// - ALREADY_ACTIVE: target row is already active → 409
|
|
|
|
|
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
|
|
|
|
|
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPatch("{id:long}/reactivate")]
|
|
|
|
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
|
|
|
public async Task<IActionResult> Reactivate([FromRoute] long id)
|
|
|
|
|
{
|
|
|
|
|
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
|
|
|
|
|
new ReactivateChargeableCharConfigCommand(id));
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Deletes a ChargeableCharConfig row.
|
|
|
|
|
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
|
|
|
|
|
/// The row disappears from all current-state queries.
|
|
|
|
|
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
|
|
|
|
/// Returns 200 + { id } consistent with the Deactivate pattern.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpDelete("{id:long}")]
|
|
|
|
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
|
|
|
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
|
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
|
|
|
public async Task<IActionResult> Delete([FromRoute] long id)
|
|
|
|
|
{
|
|
|
|
|
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
|
|
|
|
|
new DeleteChargeableCharConfigCommand(id));
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
2026-04-20 12:46:07 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Request body records ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
|
|
|
|
public sealed record CreateChargeableCharConfigRequest(
|
2026-04-21 10:54:47 -03:00
|
|
|
long? ProductTypeId,
|
2026-04-20 12:46:07 -03:00
|
|
|
string Symbol,
|
|
|
|
|
string Category,
|
|
|
|
|
decimal PricePerUnit,
|
|
|
|
|
DateOnly ValidFrom);
|
|
|
|
|
|
|
|
|
|
/// <summary>PRC-001: Schedule price change request body.</summary>
|
|
|
|
|
public sealed record SchedulePriceChangeRequest(
|
|
|
|
|
decimal PricePerUnit,
|
|
|
|
|
DateOnly ValidFrom);
|