feat(application): commands/queries + IChargeableCharConfigService (PRC-001)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
||||
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
||||
///
|
||||
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
||||
/// closes any active row for (MedioId, Symbol) and inserts the new row.
|
||||
///
|
||||
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns
|
||||
/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate.
|
||||
/// The Application service applies the per-medio > global priority rule.
|
||||
/// </summary>
|
||||
public interface IChargeableCharConfigRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
|
||||
/// Closes any active row matching (MedioId, Symbol) and inserts a new one.
|
||||
/// Returns the Id of the newly inserted row.
|
||||
/// Throws:
|
||||
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
|
||||
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
|
||||
/// </summary>
|
||||
Task<long> InsertWithCloseAsync(
|
||||
long? medioId,
|
||||
string symbol,
|
||||
string category,
|
||||
decimal price,
|
||||
DateOnly validFrom,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
|
||||
/// for the specified medio, including global rows (MedioId IS NULL).
|
||||
/// The SP returns both per-medio AND global rows — callers apply priority.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
|
||||
long medioId,
|
||||
DateOnly asOfDate,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns paginated rows filtered by MedioId and IsActive.
|
||||
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||
long? medioId,
|
||||
bool activeOnly,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns total row count for the given filters (used for pagination metadata).
|
||||
/// </summary>
|
||||
Task<int> CountAsync(
|
||||
long? medioId,
|
||||
bool activeOnly,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the row with the given Id, or null if not found.
|
||||
/// </summary>
|
||||
Task<ChargeableCharConfig?> GetByIdAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
||||
/// Idempotent: no-op if already inactive.
|
||||
/// Called inside the ambient TransactionScope of the handler.
|
||||
/// </summary>
|
||||
Task DeactivateAsync(
|
||||
long id,
|
||||
DateOnly today,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -83,6 +83,12 @@ using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
using SIGCM2.Application.ProductTypes.List;
|
||||
using SIGCM2.Application.ProductTypes.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -200,6 +206,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||
|
||||
// ChargeableCharConfig (PRC-001)
|
||||
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
||||
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user