feat(application): commands/queries + IChargeableCharConfigService (PRC-001)

This commit is contained in:
2026-04-20 12:24:06 -03:00
parent ded76fcdc7
commit f1b38cd9ce
29 changed files with 1538 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
/// </summary>
public sealed record ChargeableCharConfigDto(
long Id,
long? MedioId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom,
DateOnly? ValidTo,
bool IsActive);

View File

@@ -0,0 +1,43 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Implements IChargeableCharConfigService.
/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies
/// the per-medio > global priority rule in memory.
///
/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and
/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application.
/// </summary>
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
{
private readonly IChargeableCharConfigRepository _repo;
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
long medioId,
DateOnly asOf,
CancellationToken ct = default)
{
var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct);
// Build a dictionary keyed by Symbol.
// Per-medio rows (MedioId != null) take priority over global rows (MedioId == null).
var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
// Two-pass: first add global rows, then overwrite with per-medio rows.
foreach (var row in allRows.Where(r => r.MedioId is null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
foreach (var row in allRows.Where(r => r.MedioId is not null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
return result;
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
/// at the time of word counting. Used by IChargeableCharConfigService.
/// Keyed by Symbol in the returned dictionary.
/// </summary>
public sealed record ChargeableCharSnapshot(
string Category,
decimal PricePerUnit);

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Command to create a new ChargeableCharConfig.
/// MedioId = null → global config. MedioId set → per-medio config.
/// </summary>
public sealed record CreateChargeableCharConfigCommand(
long? MedioId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -0,0 +1,69 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
/// </summary>
public sealed class CreateChargeableCharConfigCommandHandler
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
{
long newId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
command.MedioId,
command.Symbol,
command.Category,
command.PricePerUnit,
command.ValidFrom);
await _audit.LogAsync(
action: "tasacion.chargeable_char.create",
targetType: "ChargeableCharConfig",
targetId: newId.ToString(),
metadata: new
{
after = new
{
command.MedioId,
command.Symbol,
command.Category,
command.PricePerUnit,
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
}
});
tx.Complete();
}
return new CreateChargeableCharConfigResponse(
newId,
command.Symbol,
command.PricePerUnit,
command.ValidFrom);
}
}

View File

@@ -0,0 +1,38 @@
using FluentValidation;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
/// </summary>
public sealed class CreateChargeableCharConfigCommandValidator
: AbstractValidator<CreateChargeableCharConfigCommand>
{
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
RuleFor(x => x.Symbol)
.NotEmpty()
.WithMessage("Symbol no puede estar vacío.")
.MaximumLength(4)
.WithMessage("Symbol no puede exceder 4 caracteres.");
RuleFor(x => x.Category)
.NotEmpty()
.WithMessage("Category no puede estar vacío.")
.Must(ChargeableCharCategories.IsValid)
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
RuleFor(x => x.PricePerUnit)
.GreaterThan(0m)
.WithMessage("PricePerUnit debe ser > 0.");
RuleFor(x => x.ValidFrom)
.GreaterThanOrEqualTo(today)
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
/// </summary>
public sealed record CreateChargeableCharConfigResponse(
long Id,
string Symbol,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
/// </summary>
public sealed record DeactivateChargeableCharConfigCommand(long Id);

View File

@@ -0,0 +1,70 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
/// </summary>
public sealed class DeactivateChargeableCharConfigCommandHandler
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<DeactivateChargeableCharConfigResponse> Handle(
DeactivateChargeableCharConfigCommand command)
{
var today = _timeProvider.GetArgentinaToday();
// 1. Load existing — ensures the row exists.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
// 2. TX + deactivate + audit (fail-closed).
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _repo.DeactivateAsync(command.Id, today);
await _audit.LogAsync(
action: "tasacion.chargeable_char.deactivate",
targetType: "ChargeableCharConfig",
targetId: command.Id.ToString(),
metadata: new
{
before = new
{
id = existing.Id,
symbol = existing.Symbol,
medioId = existing.MedioId,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
deactivatedOn = today.ToString("yyyy-MM-dd"),
});
tx.Complete();
}
return new DeactivateChargeableCharConfigResponse(
Id: command.Id,
ValidTo: today);
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
/// </summary>
public sealed record DeactivateChargeableCharConfigResponse(
long Id,
DateOnly ValidTo);

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
/// <summary>
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
/// Returns null if not found (caller decides whether to 404).
/// </summary>
public sealed record GetChargeableCharConfigByIdQuery(long Id);

View File

@@ -0,0 +1,37 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
/// <summary>
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
/// Returns null DTO when not found (API layer maps to 404).
/// </summary>
public sealed class GetChargeableCharConfigByIdQueryHandler
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
{
private readonly IChargeableCharConfigRepository _repo;
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
{
var entity = await _repo.GetByIdAsync(query.Id);
return entity is null ? null : ToDto(entity);
}
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
c.MedioId,
c.Symbol,
c.Category,
c.PricePerUnit,
c.ValidFrom,
c.ValidTo,
c.IsActive);
}

View File

@@ -0,0 +1,21 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Application service for resolving active chargeable-char config for a Medio.
///
/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol.
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
/// </summary>
public interface IChargeableCharConfigService
{
/// <summary>
/// Returns the resolved active config for the given medio as of the given date.
/// Per-medio rows take priority over global rows for the same Symbol.
/// Global rows are used as fallback when no per-medio row exists for that Symbol.
/// Returns an empty dictionary if no config exists at all.
/// </summary>
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
long medioId,
DateOnly asOf,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// <summary>
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
/// </summary>
public sealed record ListChargeableCharConfigQuery(
long? MedioId,
bool ActiveOnly,
int Page = 1,
int PageSize = 20);

View File

@@ -0,0 +1,46 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// <summary>
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
/// </summary>
public sealed class ListChargeableCharConfigQueryHandler
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
{
private readonly IChargeableCharConfigRepository _repo;
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var skip = (page - 1) * pageSize;
var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize);
var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly);
var dtos = items.Select(ToDto).ToList();
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
}
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
c.MedioId,
c.Symbol,
c.Category,
c.PricePerUnit,
c.ValidFrom,
c.ValidTo,
c.IsActive);
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
/// Id: the existing row whose price should be superseded.
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
/// </summary>
public sealed record SchedulePriceChangeCommand(
long Id,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -0,0 +1,80 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Handler for SchedulePriceChangeCommand.
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
/// </summary>
public sealed class SchedulePriceChangeCommandHandler
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public SchedulePriceChangeCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
{
// 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
// 2. Domain entity validates forward-only rule and builds the new entity value.
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
// 3. TX + SP + audit (fail-closed).
long newId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
newEntity.MedioId,
newEntity.Symbol,
newEntity.Category,
newEntity.PricePerUnit,
newEntity.ValidFrom);
await _audit.LogAsync(
action: "tasacion.chargeable_char.price_change",
targetType: "ChargeableCharConfig",
targetId: newId.ToString(),
metadata: new
{
before = new
{
id = existing.Id,
pricePerUnit = existing.PricePerUnit,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
after = new
{
pricePerUnit = newEntity.PricePerUnit,
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
}
});
tx.Complete();
}
return new SchedulePriceChangeResponse(
NewId: newId,
PreviousValidFrom: existing.ValidFrom,
NewValidFrom: newEntity.ValidFrom);
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
/// where the existing entity is loaded.
/// </summary>
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
{
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
RuleFor(x => x.Id)
.GreaterThan(0L)
.WithMessage("Id debe ser un entero positivo.");
RuleFor(x => x.PricePerUnit)
.GreaterThan(0m)
.WithMessage("PricePerUnit debe ser > 0.");
RuleFor(x => x.ValidFrom)
.GreaterThanOrEqualTo(today)
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Response for SchedulePriceChangeCommand.
/// </summary>
public sealed record SchedulePriceChangeResponse(
long NewId,
DateOnly PreviousValidFrom,
DateOnly NewValidFrom);