refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
Domain, Application, Infrastructure, API, all test projects.
Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).
Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
→ ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.
Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
KeyNotFoundException → 404 via ExceptionFilter.
Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
This commit is contained in:
@@ -6,8 +6,10 @@ 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.Delete;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
@@ -39,7 +41,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated list of ChargeableCharConfig rows.
|
||||
/// Filters: medioId (optional, long?), activeOnly (bool, default true).
|
||||
/// Filters: productTypeId (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.
|
||||
/// </summary>
|
||||
@@ -49,7 +51,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] long? medioId,
|
||||
[FromQuery] long? productTypeId,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] int? page = null,
|
||||
[FromQuery] int? pageSize = null,
|
||||
@@ -74,7 +76,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
|
||||
}
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize);
|
||||
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
|
||||
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
@@ -100,7 +102,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists.
|
||||
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
|
||||
/// Returns 201 Created with Location header pointing to GET /{id}.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
@@ -113,7 +115,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
||||
{
|
||||
var command = new CreateChargeableCharConfigCommand(
|
||||
request.MedioId,
|
||||
request.ProductTypeId,
|
||||
request.Symbol,
|
||||
request.Category,
|
||||
request.PricePerUnit,
|
||||
@@ -183,13 +185,57 @@ public sealed class ChargeableCharConfigController : ControllerBase
|
||||
new DeactivateChargeableCharConfigCommand(id));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
||||
public sealed record CreateChargeableCharConfigRequest(
|
||||
long? MedioId,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
|
||||
@@ -695,7 +695,7 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
{
|
||||
error = "chargeable_char_forward_only",
|
||||
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
|
||||
medioId = forwardOnlyCharEx.MedioId,
|
||||
productTypeId = forwardOnlyCharEx.ProductTypeId,
|
||||
symbol = forwardOnlyCharEx.Symbol,
|
||||
newValidFrom = forwardOnlyCharEx.NewValidFrom,
|
||||
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
|
||||
@@ -707,6 +707,33 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "chargeable_char_reactivation_not_allowed",
|
||||
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
|
||||
id = reactivationEx.Id,
|
||||
reason = reactivationEx.Reason,
|
||||
message = reactivationEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case KeyNotFoundException keyNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "not_found",
|
||||
message = keyNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ValidationException validationEx:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
|
||||
@@ -7,24 +7,24 @@ namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
/// 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.
|
||||
/// closes any active row for (ProductTypeId, 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.
|
||||
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
|
||||
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
|
||||
/// The Application service applies the per-ProductType > 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.
|
||||
/// Closes any active row matching (ProductTypeId, 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,
|
||||
long? productTypeId,
|
||||
string symbol,
|
||||
string category,
|
||||
decimal price,
|
||||
@@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository
|
||||
|
||||
/// <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.
|
||||
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
|
||||
/// The SP returns both per-ProductType AND global rows — callers apply priority.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
|
||||
long medioId,
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOfDate,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns paginated rows filtered by MedioId and IsActive.
|
||||
/// Returns paginated rows filtered by ProductTypeId and IsActive.
|
||||
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||
long? medioId,
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
int skip,
|
||||
int take,
|
||||
@@ -56,7 +56,7 @@ public interface IChargeableCharConfigRepository
|
||||
/// Returns total row count for the given filters (used for pagination metadata).
|
||||
/// </summary>
|
||||
Task<int> CountAsync(
|
||||
long? medioId,
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
CancellationToken ct = default);
|
||||
|
||||
@@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository
|
||||
long id,
|
||||
DateOnly today,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
|
||||
/// Guard rules (enforced by SP):
|
||||
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||
/// 50404 → row not found → ChargeableCharConfigInvalidException
|
||||
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
|
||||
/// </summary>
|
||||
Task<ChargeableCharConfig> ReactivateAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
|
||||
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
|
||||
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
|
||||
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||
/// Throws KeyNotFoundException if the row does not exist.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ 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.Reactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
@@ -210,6 +212,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
||||
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
/// </summary>
|
||||
public sealed record ChargeableCharConfigDto(
|
||||
long Id,
|
||||
long? MedioId,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Implements IChargeableCharConfigService.
|
||||
/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies
|
||||
/// the per-medio > global priority rule in memory.
|
||||
/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
|
||||
/// the per-ProductType > 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.
|
||||
/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
|
||||
/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
||||
{
|
||||
@@ -20,22 +20,22 @@ public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
|
||||
long medioId,
|
||||
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOf,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct);
|
||||
var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
|
||||
|
||||
// Build a dictionary keyed by Symbol.
|
||||
// Per-medio rows (MedioId != null) take priority over global rows (MedioId == null).
|
||||
// Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == 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))
|
||||
// Two-pass: first add global rows, then overwrite with per-ProductType rows.
|
||||
foreach (var row in allRows.Where(r => r.ProductTypeId is null))
|
||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||
|
||||
foreach (var row in allRows.Where(r => r.MedioId is not null))
|
||||
foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
|
||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -2,10 +2,10 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to create a new ChargeableCharConfig.
|
||||
/// MedioId = null → global config. MedioId set → per-medio config.
|
||||
/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
|
||||
/// </summary>
|
||||
public sealed record CreateChargeableCharConfigCommand(
|
||||
long? MedioId,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
newId = await _repo.InsertWithCloseAsync(
|
||||
command.MedioId,
|
||||
command.ProductTypeId,
|
||||
command.Symbol,
|
||||
command.Category,
|
||||
command.PricePerUnit,
|
||||
@@ -49,7 +49,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
|
||||
{
|
||||
after = new
|
||||
{
|
||||
command.MedioId,
|
||||
command.ProductTypeId,
|
||||
command.Symbol,
|
||||
command.Category,
|
||||
command.PricePerUnit,
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class DeactivateChargeableCharConfigCommandHandler
|
||||
{
|
||||
id = existing.Id,
|
||||
symbol = existing.Symbol,
|
||||
medioId = existing.MedioId,
|
||||
productTypeId = existing.ProductTypeId,
|
||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
deactivatedOn = today.ToString("yyyy-MM-dd"),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
|
||||
/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
|
||||
/// (SysEndTime = delete time). The row disappears from all current-state queries but
|
||||
/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
|
||||
/// to the FAC-001 followup issue.
|
||||
/// </summary>
|
||||
public sealed record DeleteChargeableCharConfigCommand(long Id);
|
||||
@@ -0,0 +1,75 @@
|
||||
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.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
|
||||
/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
|
||||
///
|
||||
/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
|
||||
/// SysEndTime = deletion timestamp. This means:
|
||||
/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
|
||||
/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
|
||||
/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
|
||||
///
|
||||
/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
||||
/// </summary>
|
||||
public sealed class DeleteChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeleteChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<DeleteChargeableCharConfigResponse> Handle(
|
||||
DeleteChargeableCharConfigCommand command)
|
||||
{
|
||||
// 1. Load existing — ensures the row exists before opening TX.
|
||||
var existing = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||
|
||||
// 2. TX + delete + audit (fail-closed).
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _repo.DeleteAsync(command.Id);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.delete",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new
|
||||
{
|
||||
id = existing.Id,
|
||||
symbol = existing.Symbol,
|
||||
productTypeId = existing.ProductTypeId,
|
||||
isActive = existing.IsActive,
|
||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new DeleteChargeableCharConfigResponse(Id: command.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for a successful delete operation.
|
||||
/// </summary>
|
||||
public sealed record DeleteChargeableCharConfigResponse(long Id);
|
||||
@@ -27,7 +27,7 @@ public sealed class GetChargeableCharConfigByIdQueryHandler
|
||||
|
||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||
c.Id,
|
||||
c.MedioId,
|
||||
c.ProductTypeId,
|
||||
c.Symbol,
|
||||
c.Category,
|
||||
c.PricePerUnit,
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Application service for resolving active chargeable-char config for a Medio.
|
||||
/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
|
||||
///
|
||||
/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol.
|
||||
/// Priority rule: per-ProductType row overrides global (ProductTypeId 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 the resolved active config for the given ProductType as of the given date.
|
||||
/// Per-ProductType rows take priority over global rows for the same Symbol.
|
||||
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
|
||||
/// Returns an empty dictionary if no config exists at all.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
|
||||
long medioId,
|
||||
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOf,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
|
||||
/// </summary>
|
||||
public sealed record ListChargeableCharConfigQuery(
|
||||
long? MedioId,
|
||||
long? ProductTypeId,
|
||||
bool ActiveOnly,
|
||||
int Page = 1,
|
||||
int PageSize = 20);
|
||||
|
||||
@@ -26,8 +26,8 @@ public sealed class ListChargeableCharConfigQueryHandler
|
||||
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 items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
|
||||
var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
|
||||
|
||||
var dtos = items.Select(ToDto).ToList();
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class ListChargeableCharConfigQueryHandler
|
||||
|
||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||
c.Id,
|
||||
c.MedioId,
|
||||
c.ProductTypeId,
|
||||
c.Symbol,
|
||||
c.Category,
|
||||
c.PricePerUnit,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
|
||||
/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
|
||||
/// </summary>
|
||||
public sealed record ReactivateChargeableCharConfigCommand(long Id);
|
||||
@@ -0,0 +1,71 @@
|
||||
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.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
|
||||
/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
|
||||
///
|
||||
/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
|
||||
/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
|
||||
/// ExceptionFilter which maps them to HTTP 409.
|
||||
/// </summary>
|
||||
public sealed class ReactivateChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReactivateChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ReactivateChargeableCharConfigResponse> Handle(
|
||||
ReactivateChargeableCharConfigCommand command)
|
||||
{
|
||||
// Open TX before calling SP so that audit failure rolls back the SP work.
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
// SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
|
||||
// Returns the reactivated entity so we can populate the response and the audit log.
|
||||
var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.reactivate",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
id = reactivated.Id,
|
||||
symbol = reactivated.Symbol,
|
||||
productTypeId = reactivated.ProductTypeId,
|
||||
validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ReactivateChargeableCharConfigResponse(
|
||||
Id: reactivated.Id,
|
||||
ProductTypeId: reactivated.ProductTypeId,
|
||||
Symbol: reactivated.Symbol,
|
||||
Category: reactivated.Category,
|
||||
PricePerUnit: reactivated.PricePerUnit,
|
||||
ValidFrom: reactivated.ValidFrom,
|
||||
IsActive: reactivated.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for a successful reactivation.
|
||||
/// Returns the current state of the row after it has been re-opened.
|
||||
/// </summary>
|
||||
public sealed record ReactivateChargeableCharConfigResponse(
|
||||
long Id,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom,
|
||||
bool IsActive);
|
||||
@@ -28,7 +28,7 @@ public sealed class SchedulePriceChangeCommandHandler
|
||||
|
||||
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
|
||||
{
|
||||
// 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
|
||||
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
|
||||
var existing = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class SchedulePriceChangeCommandHandler
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
newId = await _repo.InsertWithCloseAsync(
|
||||
newEntity.MedioId,
|
||||
newEntity.ProductTypeId,
|
||||
newEntity.Symbol,
|
||||
newEntity.Category,
|
||||
newEntity.PricePerUnit,
|
||||
|
||||
@@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
/// <summary>
|
||||
/// PRC-001 — Rich domain entity for chargeable character configuration.
|
||||
/// Represents a price-per-occurrence for a special character in classified ad text,
|
||||
/// scoped to a Medio (MedioId) or global (MedioId = null).
|
||||
/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null).
|
||||
///
|
||||
/// Forward-only price history: each new price schedules a NEW row; the current row
|
||||
/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
|
||||
/// this instance — it returns a new one. The actual close+insert happens in the repository.
|
||||
///
|
||||
/// MedioId = null → global default (lowest priority, overridden by per-medio row).
|
||||
/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row).
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfig
|
||||
{
|
||||
public long Id { get; }
|
||||
public int? MedioId { get; }
|
||||
public int? ProductTypeId { get; }
|
||||
public string Symbol { get; }
|
||||
public string Category { get; }
|
||||
public decimal PricePerUnit { get; private set; }
|
||||
@@ -25,11 +25,11 @@ public sealed class ChargeableCharConfig
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
private ChargeableCharConfig(
|
||||
long id, int? medioId, string symbol, string category,
|
||||
long id, int? productTypeId, string symbol, string category,
|
||||
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
|
||||
{
|
||||
Id = id;
|
||||
MedioId = medioId;
|
||||
ProductTypeId = productTypeId;
|
||||
Symbol = symbol;
|
||||
Category = category;
|
||||
PricePerUnit = price;
|
||||
@@ -43,7 +43,7 @@ public sealed class ChargeableCharConfig
|
||||
/// Id is set to 0 until the entity is persisted.
|
||||
/// </summary>
|
||||
public static ChargeableCharConfig Create(
|
||||
int? medioId, string symbol, string category, decimal price, DateOnly validFrom)
|
||||
int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
throw new ChargeableCharConfigInvalidException(
|
||||
@@ -61,7 +61,7 @@ public sealed class ChargeableCharConfig
|
||||
throw new ChargeableCharConfigInvalidException(
|
||||
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
|
||||
|
||||
return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true);
|
||||
return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig
|
||||
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
|
||||
/// </summary>
|
||||
public static ChargeableCharConfig Rehydrate(
|
||||
long id, int? medioId, string symbol, string category,
|
||||
long id, int? productTypeId, string symbol, string category,
|
||||
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
|
||||
=> new(id, medioId, symbol, category, price, validFrom, validTo, isActive);
|
||||
=> new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive);
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a new price (forward-only semantics).
|
||||
@@ -93,10 +93,10 @@ public sealed class ChargeableCharConfig
|
||||
$"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
|
||||
|
||||
if (newValidFrom <= ValidFrom)
|
||||
throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom);
|
||||
throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom);
|
||||
|
||||
// Create validates price > 0 and category — reuse factory
|
||||
return Create(MedioId, Symbol, Category, newPrice, newValidFrom);
|
||||
return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,19 +8,19 @@ namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigForwardOnlyException : DomainException
|
||||
{
|
||||
public int? MedioId { get; }
|
||||
public int? ProductTypeId { get; }
|
||||
public string Symbol { get; }
|
||||
public DateOnly NewValidFrom { get; }
|
||||
public DateOnly ActiveValidFrom { get; }
|
||||
|
||||
public ChargeableCharConfigForwardOnlyException(
|
||||
int? medioId,
|
||||
int? productTypeId,
|
||||
string symbol,
|
||||
DateOnly newValidFrom,
|
||||
DateOnly activeValidFrom)
|
||||
: base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).")
|
||||
{
|
||||
MedioId = medioId;
|
||||
ProductTypeId = productTypeId;
|
||||
Symbol = symbol;
|
||||
NewValidFrom = newValidFrom;
|
||||
ActiveValidFrom = activeValidFrom;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Thrown when a reactivation attempt is blocked by a guard rule.
|
||||
/// Maps to HTTP 409.
|
||||
///
|
||||
/// Reason codes:
|
||||
/// ALREADY_ACTIVE — target row is currently active (50410)
|
||||
/// VIGENTE_EXISTS — a different active row exists for (ProductTypeId, Symbol) (50411)
|
||||
/// POSTERIOR_ROWS_EXIST — rows with higher ValidFrom exist after the target row (50412)
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
|
||||
{
|
||||
public long Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
|
||||
/// </summary>
|
||||
public string Reason { get; }
|
||||
|
||||
public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
|
||||
: base($"Reactivation not allowed for config {id}: {reason}")
|
||||
{
|
||||
Id = id;
|
||||
Reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,27 @@ namespace SIGCM2.Infrastructure.Persistence;
|
||||
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
|
||||
///
|
||||
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
|
||||
/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found)
|
||||
/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
|
||||
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
|
||||
///
|
||||
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio.
|
||||
/// Returns all rows (global + per-medio) — the Application service applies priority.
|
||||
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
|
||||
/// Returns all rows (global + per-ProductType) — the Application service applies priority.
|
||||
///
|
||||
/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps:
|
||||
/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||
/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||
/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||
/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found)
|
||||
///
|
||||
/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException.
|
||||
/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current
|
||||
/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the
|
||||
/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved.
|
||||
///
|
||||
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
|
||||
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
|
||||
///
|
||||
/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
|
||||
/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
|
||||
{
|
||||
@@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<long> InsertWithCloseAsync(
|
||||
long? medioId,
|
||||
long? productTypeId,
|
||||
string symbol,
|
||||
string category,
|
||||
decimal price,
|
||||
@@ -41,14 +52,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var p = new DynamicParameters();
|
||||
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021)
|
||||
p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32);
|
||||
p.Add("@Symbol", symbol, DbType.String, size: 4);
|
||||
p.Add("@Category", category, DbType.String, size: 32);
|
||||
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
|
||||
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
|
||||
p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
|
||||
p.Add("@Symbol", symbol, DbType.String, size: 4);
|
||||
p.Add("@Category", category, DbType.String, size: 32);
|
||||
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
|
||||
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
@@ -64,16 +75,16 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50404)
|
||||
{
|
||||
// Medio not found (SP validates MedioId when not null)
|
||||
// ProductType not found (SP validates ProductTypeId when not null)
|
||||
throw new ChargeableCharConfigInvalidException(
|
||||
nameof(medioId),
|
||||
$"Medio with Id={medioId} not found.");
|
||||
nameof(productTypeId),
|
||||
$"ProductType with Id={productTypeId} not found.");
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50409)
|
||||
{
|
||||
// Forward-only violation: new ValidFrom <= active.ValidFrom
|
||||
throw new ChargeableCharConfigForwardOnlyException(
|
||||
medioId.HasValue ? (int?)checked((int)medioId.Value) : null,
|
||||
productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
|
||||
symbol,
|
||||
validFrom,
|
||||
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
|
||||
@@ -83,22 +94,22 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
|
||||
long medioId,
|
||||
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOfDate,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var p = new DynamicParameters();
|
||||
// SP @MedioId is INT
|
||||
p.Add("@MedioId", checked((int)medioId), DbType.Int32);
|
||||
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||
// SP @ProductTypeId is INT
|
||||
p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
|
||||
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
|
||||
new CommandDefinition(
|
||||
"dbo.usp_ChargeableCharConfig_GetActiveForMedio",
|
||||
"dbo.usp_ChargeableCharConfig_GetActiveForProductType",
|
||||
p,
|
||||
commandType: CommandType.StoredProcedure,
|
||||
cancellationToken: ct));
|
||||
@@ -108,20 +119,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||
long? medioId,
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// NULL-aware MedioId filter:
|
||||
// - medioId provided → filter to that medio only
|
||||
// - medioId null → return all rows regardless of medio
|
||||
// NULL-aware ProductTypeId filter:
|
||||
// - productTypeId provided → filter to that ProductType only
|
||||
// - productTypeId null → return all rows regardless of ProductType
|
||||
// activeOnly filters by IsActive = 1.
|
||||
const string sql = """
|
||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
|
||||
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
|
||||
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||
ORDER BY ValidFrom DESC, Id DESC
|
||||
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
|
||||
@@ -135,10 +146,10 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
sql,
|
||||
new
|
||||
{
|
||||
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
|
||||
ActiveOnly = activeOnly ? 1 : 0,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
|
||||
ActiveOnly = activeOnly ? 1 : 0,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
},
|
||||
cancellationToken: ct));
|
||||
|
||||
@@ -147,14 +158,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> CountAsync(
|
||||
long? medioId,
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
|
||||
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
|
||||
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||
""";
|
||||
|
||||
@@ -166,8 +177,8 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
sql,
|
||||
new
|
||||
{
|
||||
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
|
||||
ActiveOnly = activeOnly ? 1 : 0
|
||||
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
|
||||
ActiveOnly = activeOnly ? 1 : 0
|
||||
},
|
||||
cancellationToken: ct));
|
||||
}
|
||||
@@ -178,7 +189,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
@@ -221,24 +232,91 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
||||
cancellationToken: ct));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ChargeableCharConfig> ReactivateAsync(
|
||||
long id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var p = new DynamicParameters();
|
||||
p.Add("@Id", id, DbType.Int64);
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"dbo.usp_ChargeableCharConfig_ReactivateWithGuard",
|
||||
p,
|
||||
commandType: CommandType.StoredProcedure,
|
||||
cancellationToken: ct));
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50404)
|
||||
{
|
||||
throw new ChargeableCharConfigInvalidException(
|
||||
nameof(id), $"ChargeableCharConfig with Id={id} not found.");
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50410)
|
||||
{
|
||||
throw new ChargeableCharConfigReactivationNotAllowedException(id, "ALREADY_ACTIVE");
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50411)
|
||||
{
|
||||
throw new ChargeableCharConfigReactivationNotAllowedException(id, "VIGENTE_EXISTS");
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 50412)
|
||||
{
|
||||
throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
|
||||
}
|
||||
|
||||
// Fetch the reactivated row to return its current state.
|
||||
var reactivated = await GetByIdAsync(id, ct);
|
||||
return reactivated
|
||||
?? throw new ChargeableCharConfigInvalidException(
|
||||
nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteAsync(
|
||||
long id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// NOTE: With SYSTEM_VERSIONING ON on dbo.ChargeableCharConfig, this DELETE moves
|
||||
// the row to dbo.ChargeableCharConfig_History (SysEndTime = deletion timestamp).
|
||||
// The row disappears from current-state queries but is still queryable via
|
||||
// FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||
// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
||||
const string sql = "DELETE FROM dbo.ChargeableCharConfig WHERE Id = @Id";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rowsAffected = await connection.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||
|
||||
if (rowsAffected == 0)
|
||||
throw new KeyNotFoundException($"ChargeableCharConfig with Id={id} not found.");
|
||||
}
|
||||
|
||||
// ── Row mapper ────────────────────────────────────────────────────────────
|
||||
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
|
||||
// Same pattern as ProductPriceRepository.
|
||||
|
||||
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
|
||||
=> ChargeableCharConfig.Rehydrate(
|
||||
id: r.Id,
|
||||
medioId: r.MedioId,
|
||||
symbol: r.Symbol,
|
||||
category: r.Category,
|
||||
price: r.PricePerUnit,
|
||||
validFrom: DateOnly.FromDateTime(r.ValidFrom),
|
||||
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
|
||||
isActive: r.IsActive);
|
||||
id: r.Id,
|
||||
productTypeId: r.ProductTypeId,
|
||||
symbol: r.Symbol,
|
||||
category: r.Category,
|
||||
price: r.PricePerUnit,
|
||||
validFrom: DateOnly.FromDateTime(r.ValidFrom),
|
||||
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
|
||||
isActive: r.IsActive);
|
||||
|
||||
private sealed record ChargeableCharConfigRow(
|
||||
long Id,
|
||||
int? MedioId,
|
||||
int? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
|
||||
Reference in New Issue
Block a user