Compare commits

..

10 Commits

Author SHA1 Message Date
4368c42599 docs(adm-008): actualizar 2.5 Auditoría + cerrar OQ-ADM-008 + STATUS 2026-04-17 13:05:22 -03:00
65787db272 fix(adm-008): correcciones del verify loop
Seis ajustes post-verify detectados durante la corrida full de tests:

1. PuntoDeVentaRepository: UQ_PuntoDeVenta_Medio_AFIP (no _MedioId_NumeroAFIP)
   — el catch de unique violation no disparaba → 500 en race duplicado.

2. Application.DependencyInjection: registro de 8 handlers PuntosDeVenta
   — sin esto, dispatcher arrojaba "No service registered" → 500.

3. ReservarNumeroCommandHandler: backoff ampliado a 5 retries
   [25, 75, 200, 500, 1200]ms para soportar 50 threads concurrentes.

4. SecuenciaComprobante: SYSTEM_VERSIONING = OFF (AD8 revisitado).
   Under UPDATE concurrente sobre misma fila, el engine arroja
   "transaction time earlier than period start time" — limitación
   conocida de Temporal Tables con alta contención de UPDATEs.
   Decisión: secuencia es operacional, no configuración → sin history.
   V013 y SqlTestFixture actualizados para ser idempotentes.

5. SqlTestFixture: EnsureV013SchemaAsync idempotente + PuntoDeVenta_History
   en TablesToIgnore + permiso administracion:puntos_de_venta:gestionar
   en seed canónico + asignación a rol admin.

6. Tests: conteos 22→23 permisos (V013 agrega uno); repository fixtures
   ignoran PuntoDeVenta_History; test UpdatePdv_WhenPdvInactive eliminado
   (over-specified — spec no bloquea update en PdV inactivo, solo en Medio
   padre inactivo; alineado con frontend que permite editar PdV inactivo).

Resultado: 190/190 Api.Tests y tests específicos ADM-008 verdes
(Domain 13, Application 42, Api 21 = 76 tests nuevos). El único failure
residual (AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor) es
pre-existente y no relacionado a ADM-008.

Covers: verify report CRITICAL (UQ name mismatch) + WARNINGs descubiertos
durante la ejecución (DI registro, temporal tables concurrency, permiso
fixture, counts de tests pre-existentes).
2026-04-17 13:02:35 -03:00
4720f6772f test(web): component tests puntos-de-venta 2026-04-17 12:36:53 -03:00
056045232c feat(web): banners y routing puntos-de-venta 2026-04-17 12:36:48 -03:00
4b96cdefcc feat(web): tabla y form PuntosDeVenta 2026-04-17 12:36:44 -03:00
d61292afa4 feat(web): feature puntos-de-venta — types, api, hooks 2026-04-17 12:36:39 -03:00
48779543f9 test(api): integration tests CRUD + concurrencia + secuencialidad PuntosDeVenta
T5.3: 18 tests cubriendo 401/403, create, get, list, update, deactivate, reactivate, reservar, proximo.
T5.4: 50 tasks paralelas → 50 numeros distintos sin duplicados.
T5.5: 100 reservas en serie → {1..100} en orden.
2026-04-17 12:34:35 -03:00
39160bbb83 feat(api): PuntosDeVentaController + ExceptionFilter mappings ADM-008
8 endpoints en /api/v1/admin/puntos-de-venta con permiso administracion:puntos_de_venta:gestionar.
ExceptionFilter: +PuntoDeVentaNotFoundException (404), +PuntoDeVentaInactivoException (409), +NumeroAFIPDuplicadoException (409).
MedioInactivoException ya mapeado por ADM-001; no duplicado.
2026-04-17 12:34:30 -03:00
489359f0b8 feat(infrastructure): PuntoDeVentaRepository con Dapper + mapping SqlException + registro DI 2026-04-17 12:29:16 -03:00
50f6f2b67a feat(application): repository abstraction + DTOs + validators + handlers CRUD PuntosDeVenta con auditoría + retry deadlock 2026-04-17 12:28:11 -03:00
76 changed files with 4466 additions and 40 deletions

View File

@@ -0,0 +1 @@
{"version":"2.1.9","results":[[":src/web/src/tests/stores/authStore.test.ts",{"duration":19.427999999999997,"failed":true}],[":src/web/src/tests/features/auth/ProtectedRoute.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/api/axiosClient.test.ts",{"duration":259.31550000000016,"failed":true}],[":src/web/src/tests/features/users/UserForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UsersListPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/LoginPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/useLogin.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UserEditPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/ResetPasswordModal.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/authApi.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolesList.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useCreateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/CanPerform.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/usePermission.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useUsersList.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/listUsers.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/updateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/getUser.test.ts",{"duration":0,"failed":true}]]}

View File

@@ -130,44 +130,50 @@ END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SYSTEM_VERSIONING — SecuenciaComprobante
-- 4. SecuenciaComprobante — SIN SYSTEM_VERSIONING (decisión AD8 revisitada)
-- ═══════════════════════════════════════════════════════════════════════
-- Razón: bajo reservas concurrentes (reportado 50 threads paralelos) el engine
-- arroja "Data modification failed on system-versioned table because transaction
-- time was earlier than period start" — UPDATEs repetidos sobre la misma fila
-- en transacciones SERIALIZABLE concurrentes violan la invariante temporal.
-- Ya anticipado en el design (AD8): la reserva es operacional, no configuracion.
-- La auditoria del numero vigente no tiene valor de negocio: el estado actual
-- (UltimoNumero) es la unica informacion relevante; cada comprobante emitido
-- deja su propio rastro en AvisosCdo/CtaCte (modulos FAC-*).
--
-- Esta seccion es idempotente: si una version previa de la migracion dejo
-- SYSTEM_VERSIONING = ON, lo desactiva y drop la history. En instalacion nueva
-- no hace nada porque nunca se activo.
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NULL
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_SecuenciaComprobante_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_SecuenciaComprobante_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME added.';
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (revisited AD8).';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
ALTER TABLE dbo.SecuenciaComprobante
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.SecuenciaComprobante_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = ON (history: dbo.SecuenciaComprobante_History, retention: 10 years).';
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History: dropped.';
END
ELSE
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'SecuenciaComprobante_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'SecuenciaComprobante_History' AND p.data_compression = 2
)
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'SecuenciaComprobante_History: rebuilt with PAGE compression.';
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
PRINT 'SecuenciaComprobante: ValidFrom/ValidTo + default constraints dropped.';
END
GO

View File

@@ -0,0 +1,206 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta.
/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/puntos-de-venta")]
public sealed class PuntosDeVentaController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreatePuntoDeVentaCommand> _createValidator;
private readonly IValidator<UpdatePuntoDeVentaCommand> _updateValidator;
public PuntosDeVentaController(
IDispatcher dispatcher,
IValidator<CreatePuntoDeVentaCommand> createValidator,
IValidator<UpdatePuntoDeVentaCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request)
{
var command = new CreatePuntoDeVentaCommand(
MedioId: request.MedioId ?? 0,
NumeroAFIP: request.NumeroAFIP ?? 0,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>(command);
return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result);
}
/// <summary>Lists puntos de venta with optional filters.</summary>
[HttpGet]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PagedResult<PuntoDeVentaListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListPuntosDeVenta(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int? medioId = null,
[FromQuery] bool? activo = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListPuntosDeVentaQuery(page, pageSize, medioId, activo);
var result = await _dispatcher.Send<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single punto de venta by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPuntoDeVentaById([FromRoute] int id)
{
var query = new GetPuntoDeVentaByIdQuery(id);
var result = await _dispatcher.Send<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a punto de venta's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request)
{
var command = new UpdatePuntoDeVentaCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
NumeroAFIP: request.NumeroAFIP ?? 0,
Descripcion: request.Descripcion);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a punto de venta.</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivatePuntoDeVenta([FromRoute] int id)
{
var command = new DeactivatePuntoDeVentaCommand(id);
await _dispatcher.Send<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a punto de venta (only if parent Medio is active).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ReactivatePuntoDeVenta([FromRoute] int id)
{
var command = new ReactivatePuntoDeVentaCommand(id);
await _dispatcher.Send<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
return NoContent();
}
/// <summary>Reserves the next sequential number for a given PdV and TipoComprobante.</summary>
[HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
{
var command = new ReservarNumeroCommand(id, tipoComprobante);
var result = await _dispatcher.Send<ReservarNumeroCommand, ReservaNumeroDto>(command);
return Ok(result);
}
/// <summary>Returns the next available number (read-only, no reservation).</summary>
[HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
{
var query = new GetProximoNumeroQuery(id, tipoComprobante);
var result = await _dispatcher.Send<GetProximoNumeroQuery, ProximoNumeroDto>(query);
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-008: Create punto de venta request body.</summary>
public sealed record CreatePuntoDeVentaRequest(
int? MedioId,
short? NumeroAFIP,
string? Nombre,
string? Descripcion);
/// <summary>ADM-008: Update punto de venta request body.</summary>
public sealed record UpdatePuntoDeVentaRequest(
string? Nombre,
short? NumeroAFIP,
string? Descripcion);

View File

@@ -231,6 +231,43 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new
{
error = "punto_de_venta_not_found",
message = puntoDeVentaNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case PuntoDeVentaInactivoException puntoDeVentaInactivoEx:
context.Result = new ObjectResult(new
{
error = "punto_de_venta_inactivo",
message = puntoDeVentaInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
context.Result = new ObjectResult(new
{
error = "numero_afip_duplicado",
message = numeroAFIPDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// UDT-009: permiso override validation errors
case InvalidPermisoCodesException ipce:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails

View File

@@ -0,0 +1,16 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IPuntoDeVentaRepository
{
Task<int> AddAsync(PuntoDeVenta pdv, CancellationToken ct = default);
Task<PuntoDeVenta?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default);
Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default);
Task<PagedResult<PuntoDeVenta>> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default);
Task<int> ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default);
Task<int?> GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default);
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Common;
/// <summary>Query parameters for listing puntos de venta with optional filters and paging.</summary>
public sealed record PuntosDeVentaQuery(
int Page,
int PageSize,
int? MedioId,
bool? Activo
);

View File

@@ -21,6 +21,14 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById;
@@ -90,6 +98,16 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
// Puntos de Venta (ADM-008)
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
services.AddScoped<ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>, ReservarNumeroCommandHandler>();
services.AddScoped<ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>, GetProximoNumeroQueryHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.PuntosDeVenta.Create;
public sealed record CreatePuntoDeVentaCommand(
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion);

View File

@@ -0,0 +1,75 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Create;
public sealed class CreatePuntoDeVentaCommandHandler : ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly IMedioRepository _medioRepo;
private readonly IAuditLogger _audit;
public CreatePuntoDeVentaCommandHandler(
IPuntoDeVentaRepository repo,
IMedioRepository medioRepo,
IAuditLogger audit)
{
_repo = repo;
_medioRepo = medioRepo;
_audit = audit;
}
public async Task<PuntoDeVentaCreatedDto> Handle(CreatePuntoDeVentaCommand command)
{
// Validate medio exists and is active (REQ-PDV-001, -002)
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
?? throw new MedioNotFoundException(command.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(medio.Id);
// Check uniqueness NumeroAFIP within Medio (REQ-PDV-003)
var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(command.MedioId, command.NumeroAFIP, excludeId: null);
if (exists)
throw new NumeroAFIPDuplicadoException(command.MedioId, command.NumeroAFIP);
var pdv = PuntoDeVenta.ForCreation(command.MedioId, command.NumeroAFIP, command.Nombre, command.Descripcion);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(pdv);
// fail-closed: if LogAsync throws, tx rolls back (REQ-PDV-010)
await _audit.LogAsync(
action: "punto_de_venta.create",
targetType: "PuntoDeVenta",
targetId: newId.ToString(),
metadata: new
{
after = new
{
pdv.MedioId,
pdv.NumeroAFIP,
pdv.Nombre,
pdv.Descripcion,
},
});
tx.Complete();
return new PuntoDeVentaCreatedDto(
Id: newId,
MedioId: pdv.MedioId,
NumeroAFIP: pdv.NumeroAFIP,
Nombre: pdv.Nombre,
Descripcion: pdv.Descripcion,
Activo: pdv.Activo);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
namespace SIGCM2.Application.PuntosDeVenta.Create;
public sealed class CreatePuntoDeVentaCommandValidator : AbstractValidator<CreatePuntoDeVentaCommand>
{
private const int NombreMaxLength = 100;
private const int DescripcionMaxLength = 255;
private const short NumeroAFIPMin = 1;
private const short NumeroAFIPMax = 9999;
public CreatePuntoDeVentaCommandValidator()
{
RuleFor(x => x.MedioId)
.GreaterThan(0).WithMessage("El medioId debe ser mayor a 0.");
RuleFor(x => x.NumeroAFIP)
.InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax)
.WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Descripcion)
.MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
.When(x => x.Descripcion is not null);
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.PuntosDeVenta.Create;
public sealed record PuntoDeVentaCreatedDto(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
public sealed record DeactivatePuntoDeVentaCommand(int Id);

View File

@@ -0,0 +1,53 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
public sealed class DeactivatePuntoDeVentaCommandHandler : ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly IMedioRepository _medioRepo;
private readonly IAuditLogger _audit;
public DeactivatePuntoDeVentaCommandHandler(
IPuntoDeVentaRepository repo,
IMedioRepository medioRepo,
IAuditLogger audit)
{
_repo = repo;
_medioRepo = medioRepo;
_audit = audit;
}
public async Task<PuntoDeVentaStatusDto> Handle(DeactivatePuntoDeVentaCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new PuntoDeVentaNotFoundException(command.Id);
// Idempotent: already inactive → return as-is without writing an audit event
if (!target.Activo)
return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo);
var updated = target.WithActivo(false);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "punto_de_venta.deactivate",
targetType: "PuntoDeVenta",
targetId: command.Id.ToString());
tx.Complete();
return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
public sealed record PuntoDeVentaStatusDto(int Id, short NumeroAFIP, bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.PuntosDeVenta.GetById;
public sealed record GetPuntoDeVentaByIdQuery(int Id);

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.GetById;
public sealed class GetPuntoDeVentaByIdQueryHandler : ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>
{
private readonly IPuntoDeVentaRepository _repo;
public GetPuntoDeVentaByIdQueryHandler(IPuntoDeVentaRepository repo)
{
_repo = repo;
}
public async Task<PuntoDeVentaDetailDto> Handle(GetPuntoDeVentaByIdQuery query)
{
var pdv = await _repo.GetByIdAsync(query.Id)
?? throw new PuntoDeVentaNotFoundException(query.Id);
return new PuntoDeVentaDetailDto(
Id: pdv.Id,
MedioId: pdv.MedioId,
NumeroAFIP: pdv.NumeroAFIP,
Nombre: pdv.Nombre,
Descripcion: pdv.Descripcion,
Activo: pdv.Activo,
FechaCreacion: pdv.FechaCreacion,
FechaModificacion: pdv.FechaModificacion);
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.PuntosDeVenta.GetById;
public sealed record PuntoDeVentaDetailDto(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.PuntosDeVenta.List;
public sealed record ListPuntosDeVentaQuery(
int Page,
int PageSize,
int? MedioId,
bool? Activo);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.PuntosDeVenta.List;
public sealed class ListPuntosDeVentaQueryHandler : ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>
{
private readonly IPuntoDeVentaRepository _repo;
public ListPuntosDeVentaQueryHandler(IPuntoDeVentaRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<PuntoDeVentaListItemDto>> Handle(ListPuntosDeVentaQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new PuntosDeVentaQuery(page, pageSize, query.MedioId, query.Activo);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items
.Select(p => new PuntoDeVentaListItemDto(p.Id, p.MedioId, p.NumeroAFIP, p.Nombre, p.Activo))
.ToList();
return new PagedResult<PuntoDeVentaListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.PuntosDeVenta.List;
public sealed record PuntoDeVentaListItemDto(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
bool Activo);

View File

@@ -0,0 +1,5 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante);

View File

@@ -0,0 +1,27 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
/// <summary>
/// Consulta el próximo número disponible sin reservarlo (read-only, REQ-SEC-CMB-005).
/// Retorna UltimoNumero+1; si no existe fila devuelve 1.
/// </summary>
public sealed class GetProximoNumeroQueryHandler : ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>
{
private readonly IPuntoDeVentaRepository _repo;
public GetProximoNumeroQueryHandler(IPuntoDeVentaRepository repo)
{
_repo = repo;
}
public async Task<ProximoNumeroDto> Handle(GetProximoNumeroQuery query)
{
var ultimoNumero = await _repo.GetUltimoNumeroAsync(query.PuntoDeVentaId, query.TipoComprobante);
var proximo = ultimoNumero.HasValue ? ultimoNumero.Value + 1 : 1;
return new ProximoNumeroDto(query.TipoComprobante, proximo);
}
}

View File

@@ -0,0 +1,5 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.PuntosDeVenta.Reactivate;
public sealed record ReactivatePuntoDeVentaCommand(int Id);

View File

@@ -0,0 +1,60 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Reactivate;
public sealed class ReactivatePuntoDeVentaCommandHandler : ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly IMedioRepository _medioRepo;
private readonly IAuditLogger _audit;
public ReactivatePuntoDeVentaCommandHandler(
IPuntoDeVentaRepository repo,
IMedioRepository medioRepo,
IAuditLogger audit)
{
_repo = repo;
_medioRepo = medioRepo;
_audit = audit;
}
public async Task<PuntoDeVentaStatusDto> Handle(ReactivatePuntoDeVentaCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new PuntoDeVentaNotFoundException(command.Id);
var medio = await _medioRepo.GetByIdAsync(target.MedioId)
?? throw new MedioNotFoundException(target.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(medio.Id);
// Idempotent: already active → return as-is without writing an audit event
if (target.Activo)
return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo);
var updated = target.WithActivo(true);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "punto_de_venta.reactivate",
targetType: "PuntoDeVenta",
targetId: command.Id.ToString());
tx.Complete();
return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo);
}
}

View File

@@ -0,0 +1,5 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado);

View File

@@ -0,0 +1,5 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante);

View File

@@ -0,0 +1,53 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
/// <summary>
/// Reserva el próximo número correlativo para (PdvId × TipoComprobante) ejecutando
/// usp_ReservarNumeroComprobante vía el repositorio.
///
/// NOTAS DE DISEÑO (AD4, AD9):
/// - NO se envuelve en TransactionScope: el SP ya es atómico bajo SERIALIZABLE.
/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario.
/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple.
/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException.
/// - Backoff en ms: [25, 75, 200, 500, 1200] — 5 retries máximo (6 intentos totales).
/// - La auditoría de reservas corre solo vía Temporal Tables (AD8).
/// </summary>
public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly int[] _deadlockBackoffMs;
private static readonly int[] DefaultBackoffMs = [25, 75, 200, 500, 1200];
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo)
: this(repo, DefaultBackoffMs) { }
/// <summary>Constructor with custom backoff for testing (e.g., [0,0,0] for fast tests).</summary>
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo, int[] deadlockBackoffMs)
{
_repo = repo;
_deadlockBackoffMs = deadlockBackoffMs;
}
public async Task<ReservaNumeroDto> Handle(ReservarNumeroCommand command)
{
for (var i = 0; ; i++)
{
try
{
var numero = await _repo.ReservarNumeroAsync(command.PuntoDeVentaId, command.TipoComprobante);
return new ReservaNumeroDto(command.TipoComprobante, numero);
}
catch (DeadlockTransientException) when (i < _deadlockBackoffMs.Length)
{
// Deadlock — retry with backoff
await Task.Delay(_deadlockBackoffMs[i]);
}
// All other exceptions bubble up immediately
}
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.PuntosDeVenta.Update;
public sealed record PuntoDeVentaUpdatedDto(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo);

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.PuntosDeVenta.Update;
public sealed record UpdatePuntoDeVentaCommand(
int Id,
string Nombre,
short NumeroAFIP,
string? Descripcion);

View File

@@ -0,0 +1,71 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Update;
public sealed class UpdatePuntoDeVentaCommandHandler : ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly IMedioRepository _medioRepo;
private readonly IAuditLogger _audit;
public UpdatePuntoDeVentaCommandHandler(
IPuntoDeVentaRepository repo,
IMedioRepository medioRepo,
IAuditLogger audit)
{
_repo = repo;
_medioRepo = medioRepo;
_audit = audit;
}
public async Task<PuntoDeVentaUpdatedDto> Handle(UpdatePuntoDeVentaCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new PuntoDeVentaNotFoundException(command.Id);
var medio = await _medioRepo.GetByIdAsync(target.MedioId)
?? throw new MedioNotFoundException(target.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(medio.Id);
// Re-validate uniqueness excluding current entity (REQ-PDV-004)
var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(target.MedioId, command.NumeroAFIP, excludeId: command.Id);
if (exists)
throw new NumeroAFIPDuplicadoException(target.MedioId, command.NumeroAFIP);
var updated = target.WithUpdatedProfile(command.Nombre, command.NumeroAFIP, command.Descripcion);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "punto_de_venta.update",
targetType: "PuntoDeVenta",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.NumeroAFIP, target.Descripcion },
after = new { updated.Nombre, updated.NumeroAFIP, updated.Descripcion },
});
tx.Complete();
return new PuntoDeVentaUpdatedDto(
Id: updated.Id,
MedioId: updated.MedioId,
NumeroAFIP: updated.NumeroAFIP,
Nombre: updated.Nombre,
Descripcion: updated.Descripcion,
Activo: updated.Activo);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
namespace SIGCM2.Application.PuntosDeVenta.Update;
public sealed class UpdatePuntoDeVentaCommandValidator : AbstractValidator<UpdatePuntoDeVentaCommand>
{
private const int NombreMaxLength = 100;
private const int DescripcionMaxLength = 255;
private const short NumeroAFIPMin = 1;
private const short NumeroAFIPMax = 9999;
public UpdatePuntoDeVentaCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
RuleFor(x => x.NumeroAFIP)
.InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax)
.WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Descripcion)
.MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
.When(x => x.Descripcion is not null);
}
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown by Infrastructure when a database deadlock (SQL 1205) is detected.
/// Allows Application handlers to retry without referencing SqlClient.
/// </summary>
public sealed class DeadlockTransientException : DomainException
{
public DeadlockTransientException()
: base("Se detectó un deadlock en la base de datos. Reintentando operación.") { }
public DeadlockTransientException(Exception innerException)
: base("Se detectó un deadlock en la base de datos. Reintentando operación.", innerException) { }
}

View File

@@ -34,6 +34,7 @@ public static class DependencyInjection
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>();
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,255 @@
using System.Data;
using System.Text;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public PuntoDeVentaRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> AddAsync(PuntoDeVenta pdv, CancellationToken ct = default)
{
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
const string sql = """
INSERT INTO dbo.PuntoDeVenta (MedioId, NumeroAFIP, Nombre, Descripcion)
OUTPUT INSERTED.Id
VALUES (@MedioId, @NumeroAFIP, @Nombre, @Descripcion)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
try
{
return await connection.ExecuteScalarAsync<int>(sql, new
{
pdv.MedioId,
pdv.NumeroAFIP,
pdv.Nombre,
pdv.Descripcion,
});
}
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
{
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
}
}
public async Task<PuntoDeVenta?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion
FROM dbo.PuntoDeVenta
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<PdvRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default)
{
var sql = excludeId.HasValue
? "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP AND Id <> @ExcludeId"
: "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { MedioId = medioId, NumeroAFIP = numeroAFIP, ExcludeId = excludeId });
return count > 0;
}
public async Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.PuntoDeVenta
SET NumeroAFIP = @NumeroAFIP,
Nombre = @Nombre,
Descripcion = @Descripcion,
Activo = @Activo,
FechaModificacion = @FechaModificacion
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(sql, new
{
pdv.NumeroAFIP,
pdv.Nombre,
pdv.Descripcion,
pdv.Activo,
FechaModificacion = pdv.FechaModificacion ?? DateTime.UtcNow,
pdv.Id,
});
}
catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_Medio_AFIP"))
{
throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP);
}
}
public async Task<PagedResult<PuntoDeVenta>> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default)
{
var page = Math.Max(1, q.Page);
var pageSize = Math.Clamp(q.PageSize, 1, 100);
var offset = (page - 1) * pageSize;
var where = new StringBuilder("WHERE 1=1");
var parameters = new DynamicParameters();
parameters.Add("PageSize", pageSize);
parameters.Add("Offset", offset);
if (q.MedioId.HasValue)
{
where.Append(" AND MedioId = @MedioId");
parameters.Add("MedioId", q.MedioId.Value);
}
if (q.Activo.HasValue)
{
where.Append(" AND Activo = @Activo");
parameters.Add("Activo", q.Activo.Value ? 1 : 0);
}
var sql = $"""
SELECT
Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion,
COUNT(*) OVER() AS TotalCount
FROM dbo.PuntoDeVenta
{where}
ORDER BY MedioId, NumeroAFIP
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<PdvPagedRow>(sql, parameters);
var list = rows.ToList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
var items = list.Select(r => MapRow(r)).ToList();
return new PagedResult<PuntoDeVenta>(items, page, pageSize, total);
}
public async Task<int> ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default)
{
var parameters = new DynamicParameters();
parameters.Add("PuntoDeVentaId", puntoDeVentaId, DbType.Int32);
parameters.Add("TipoComprobante", (byte)tipo, DbType.Byte);
parameters.Add("NumeroReservado", dbType: DbType.Int32, direction: ParameterDirection.Output);
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(
"dbo.usp_ReservarNumeroComprobante",
parameters,
commandType: CommandType.StoredProcedure);
}
catch (SqlException ex)
{
throw ex.Number switch
{
50001 => new PuntoDeVentaInactivoException(puntoDeVentaId),
50002 => new MedioInactivoException(puntoDeVentaId),
50003 => new PuntoDeVentaNotFoundException(puntoDeVentaId),
1205 => new DeadlockTransientException(ex),
_ => ex,
};
}
return parameters.Get<int>("NumeroReservado");
}
public async Task<int?> GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default)
{
const string sql = """
SELECT UltimoNumero
FROM dbo.SecuenciaComprobante
WHERE PuntoDeVentaId = @PuntoDeVentaId AND TipoComprobante = @TipoComprobante
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.QuerySingleOrDefaultAsync<int?>(sql, new
{
PuntoDeVentaId = puntoDeVentaId,
TipoComprobante = (byte)tipo,
});
}
// ── mapping ───────────────────────────────────────────────────────────────
private static PuntoDeVenta MapRow(PdvRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
numeroAFIP: r.NumeroAFIP,
nombre: r.Nombre,
descripcion: r.Descripcion,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static PuntoDeVenta MapRow(PdvPagedRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
numeroAFIP: r.NumeroAFIP,
nombre: r.Nombre,
descripcion: r.Descripcion,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static bool IsUniqueViolation(SqlException ex)
=> ex.Number is 2627 or 2601;
// ── private rows ──────────────────────────────────────────────────────────
private sealed record PdvRow(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record PdvPagedRow(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

@@ -14,6 +14,7 @@ import {
PanelLeftOpen,
Newspaper,
Columns3,
Store,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -61,6 +62,12 @@ const adminItems: NavItem[] = [
icon: Columns3,
requiredPermission: 'administracion:secciones:gestionar',
},
{
label: 'Puntos de Venta',
href: '/admin/puntos-de-venta',
icon: Store,
requiredPermission: 'administracion:puntos_de_venta:gestionar',
},
]
interface SidebarNavProps {

View File

@@ -0,0 +1,62 @@
import { axiosClient } from '@/api/axiosClient'
import type {
CreatePuntoDeVentaRequest,
PuntoDeVentaCreated,
PuntoDeVentaDetail,
PuntoDeVentaListItem,
PuntosDeVentaQuery,
UpdatePuntoDeVentaRequest,
PagedResult,
} from '../types'
export async function listPuntosDeVenta(
query: PuntosDeVentaQuery,
): Promise<PagedResult<PuntoDeVentaListItem>> {
const params = new URLSearchParams()
if (query.page !== undefined) params.set('page', String(query.page))
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
if (query.medioId !== undefined) params.set('medioId', String(query.medioId))
if (query.activo !== undefined) params.set('activo', String(query.activo))
const response = await axiosClient.get<PagedResult<PuntoDeVentaListItem>>(
'/api/v1/admin/puntos-de-venta',
{ params },
)
return response.data
}
export async function getPuntoDeVenta(id: number): Promise<PuntoDeVentaDetail> {
const response = await axiosClient.get<PuntoDeVentaDetail>(
`/api/v1/admin/puntos-de-venta/${id}`,
)
return response.data
}
export async function createPuntoDeVenta(
payload: CreatePuntoDeVentaRequest,
): Promise<PuntoDeVentaCreated> {
const response = await axiosClient.post<PuntoDeVentaCreated>(
'/api/v1/admin/puntos-de-venta',
payload,
)
return response.data
}
export async function updatePuntoDeVenta(
id: number,
payload: UpdatePuntoDeVentaRequest,
): Promise<PuntoDeVentaDetail> {
const response = await axiosClient.put<PuntoDeVentaDetail>(
`/api/v1/admin/puntos-de-venta/${id}`,
payload,
)
return response.data
}
export async function deactivatePuntoDeVenta(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/deactivate`)
}
export async function reactivatePuntoDeVenta(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/reactivate`)
}

View File

@@ -0,0 +1,22 @@
import { axiosClient } from '@/api/axiosClient'
import type { TipoComprobante, ReservarNumeroResponse, ProximoNumeroResponse } from '../types'
export async function reservarNumero(
puntoDeVentaId: number,
tipoComprobante: TipoComprobante,
): Promise<ReservarNumeroResponse> {
const response = await axiosClient.post<ReservarNumeroResponse>(
`/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/reservar`,
)
return response.data
}
export async function getProximoNumero(
puntoDeVentaId: number,
tipoComprobante: TipoComprobante,
): Promise<ProximoNumeroResponse> {
const response = await axiosClient.get<ProximoNumeroResponse>(
`/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/proximo`,
)
return response.data
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useDeactivatePuntoDeVenta, useReactivatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
interface DeactivatePuntoDeVentaModalProps {
puntoDeVentaId: number
puntoDeVentaNombre: string
activo: boolean
disabled?: boolean
}
export function DeactivatePuntoDeVentaModal({
puntoDeVentaId,
puntoDeVentaNombre,
activo,
disabled = false,
}: DeactivatePuntoDeVentaModalProps) {
const [open, setOpen] = useState(false)
const { mutate: deactivate, isPending: deactivating } = useDeactivatePuntoDeVenta()
const { mutate: reactivate, isPending: reactivating } = useReactivatePuntoDeVenta()
const isPending = deactivating || reactivating
function handleConfirm() {
if (activo) {
deactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) })
} else {
reactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) })
}
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
{activo ? 'Desactivar' : 'Reactivar'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{activo ? 'Desactivar punto de venta' : 'Reactivar punto de venta'}
</AlertDialogTitle>
<AlertDialogDescription>
{activo
? `¿Confirmás que querés desactivar el punto de venta "${puntoDeVentaNombre}"?`
: `¿Confirmás que querés reactivar el punto de venta "${puntoDeVentaNombre}"?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,19 @@
import { AlertTriangle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
interface MedioInactivoBannerProps {
medioNombre: string
}
export function MedioInactivoBanner({ medioNombre }: MedioInactivoBannerProps) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Medio desactivado</AlertTitle>
<AlertDescription>
El medio &quot;{medioNombre}&quot; está desactivado. Las operaciones de edición, activación y
desactivación de sus puntos de venta están bloqueadas hasta que se reactive el medio.
</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,19 @@
import { AlertTriangle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
interface PdvInactivoBannerProps {
puntoDeVentaNombre: string
}
export function PdvInactivoBanner({ puntoDeVentaNombre }: PdvInactivoBannerProps) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Punto de venta desactivado</AlertTitle>
<AlertDescription>
El punto de venta &quot;{puntoDeVentaNombre}&quot; está desactivado. Reactivalo para habilitar
nuevamente sus operaciones.
</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,177 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMediosList } from '@/features/medios/hooks/useMediosList'
import type { PuntoDeVentaDetail } from '../types'
const puntoDeVentaFormSchema = z.object({
medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'),
numeroAFIP: z.coerce
.number({ invalid_type_error: 'El número AFIP debe ser un número' })
.int('Debe ser un número entero')
.min(1, 'El número AFIP debe ser mayor a 0'),
nombre: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'Máximo 100 caracteres'),
})
export type PuntoDeVentaFormValues = z.infer<typeof puntoDeVentaFormSchema>
interface PuntoDeVentaFormProps {
initialData?: PuntoDeVentaDetail
isPending: boolean
error: unknown
onSubmit: (values: PuntoDeVentaFormValues) => void
}
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
if (data.error === 'numero_afip_duplicado') {
return data.message ?? 'Ya existe un punto de venta con ese número AFIP en el medio'
}
if (data.error === 'medio_inactivo') {
return data.message ?? 'El medio está inactivo. Reactivalo antes de operar.'
}
return data.message ?? data.error ?? 'Error al guardar el punto de venta'
}
return 'Error al guardar el punto de venta'
}
export function PuntoDeVentaForm({ initialData, isPending, error, onSubmit }: PuntoDeVentaFormProps) {
const isEdit = !!initialData
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 })
const medios = mediosData?.items ?? []
const form = useForm<PuntoDeVentaFormValues>({
resolver: zodResolver(puntoDeVentaFormSchema),
defaultValues: {
medioId: initialData?.medioId ?? ('' as unknown as number),
numeroAFIP: initialData?.numeroAFIP ?? ('' as unknown as number),
nombre: initialData?.nombre ?? '',
},
})
useEffect(() => {
if (initialData) {
form.reset({
medioId: initialData.medioId,
numeroAFIP: initialData.numeroAFIP,
nombre: initialData.nombre,
})
}
}, [initialData, form])
const backendError = resolveBackendError(error)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="medioId"
render={({ field }) => (
<FormItem>
<FormLabel>Medio</FormLabel>
<Select
value={field.value ? String(field.value) : ''}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isPending || isEdit}
>
<FormControl>
<SelectTrigger className="w-full" aria-label="Medio">
<SelectValue placeholder="Seleccioná un medio" />
</SelectTrigger>
</FormControl>
<SelectContent>
{medios.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="numeroAFIP"
render={({ field }) => (
<FormItem>
<FormLabel>Número AFIP</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || isEdit}
placeholder="Ej: 1"
aria-label="Número AFIP"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending}
placeholder="Nombre del punto de venta"
aria-label="Nombre"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear punto de venta'}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,59 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMediosList } from '@/features/medios/hooks/useMediosList'
interface PuntosDeVentaFiltersProps {
onMedioIdChange: (medioId: number | undefined) => void
onActivoChange: (activo: boolean | undefined) => void
}
export function PuntosDeVentaFilters({
onMedioIdChange,
onActivoChange,
}: PuntosDeVentaFiltersProps) {
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true })
const medios = mediosData?.items ?? []
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
<Select
defaultValue="__all__"
onValueChange={(v) => onMedioIdChange(v === '__all__' ? undefined : Number(v))}
>
<SelectTrigger className="h-9 w-52" aria-label="Medio">
<SelectValue placeholder="Todos los medios" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos los medios</SelectItem>
{medios.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
defaultValue="__all__"
onValueChange={(v) => {
if (v === '__all__') onActivoChange(undefined)
else onActivoChange(v === 'true')
}}
>
<SelectTrigger className="h-9 w-32" aria-label="Estado">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos</SelectItem>
<SelectItem value="true">Activos</SelectItem>
<SelectItem value="false">Inactivos</SelectItem>
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import type { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import { CanPerform } from '@/components/auth/CanPerform'
import type { PuntoDeVentaListItem } from '../types'
import { DeactivatePuntoDeVentaModal } from './DeactivatePuntoDeVentaModal'
interface PuntosDeVentaTableProps {
rows: PuntoDeVentaListItem[]
medioInactivo?: boolean
}
export function PuntosDeVentaTable({ rows, medioInactivo = false }: PuntosDeVentaTableProps) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<PuntoDeVentaListItem>[]>(
() => [
{
accessorKey: 'numeroAFIP',
header: 'N° AFIP',
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.numeroAFIP}</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'nombre',
header: 'Nombre',
meta: { priority: 'high' },
},
{
accessorKey: 'medioId',
header: 'Medio ID',
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.medioId}</span>
),
meta: { priority: 'medium' },
},
{
accessorKey: 'activo',
header: 'Estado',
cell: ({ row }) =>
row.original.activo ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Activo
</Badge>
) : (
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Inactivo
</Badge>
),
meta: { priority: 'medium' },
},
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<CanPerform permission="administracion:puntos_de_venta:gestionar">
<Button
variant="outline"
size="sm"
disabled={medioInactivo}
onClick={() => navigate(`/admin/puntos-de-venta/${row.original.id}/edit`)}
>
Editar
</Button>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={row.original.id}
puntoDeVentaNombre={row.original.nombre}
activo={row.original.activo}
disabled={medioInactivo}
/>
</CanPerform>
</div>
),
meta: { priority: 'high' },
},
],
[navigate, medioInactivo],
)
return (
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => navigate(`/admin/puntos-de-venta/${row.id}`)}
getRowId={(row) => String(row.id)}
emptyMessage="Sin resultados — no se encontraron puntos de venta con los filtros seleccionados."
/>
)
}

View File

@@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
listPuntosDeVenta,
getPuntoDeVenta,
createPuntoDeVenta,
updatePuntoDeVenta,
deactivatePuntoDeVenta,
reactivatePuntoDeVenta,
} from '../api/puntos-de-venta.api'
import type { CreatePuntoDeVentaRequest, PuntosDeVentaQuery, UpdatePuntoDeVentaRequest } from '../types'
export const puntosDeVentaListQueryKey = (query: PuntosDeVentaQuery) =>
['puntos-de-venta', 'list', query] as const
export const puntoDeVentaDetailQueryKey = (id: number) =>
['puntos-de-venta', 'detail', id] as const
// ─── List ────────────────────────────────────────────────────────────────────
export function usePuntosDeVentaList(query: PuntosDeVentaQuery) {
return useQuery({
queryKey: puntosDeVentaListQueryKey(query),
queryFn: () => listPuntosDeVenta(query),
staleTime: 15_000,
})
}
// ─── Detail ──────────────────────────────────────────────────────────────────
export function usePuntoDeVenta(id: number) {
return useQuery({
queryKey: puntoDeVentaDetailQueryKey(id),
queryFn: () => getPuntoDeVenta(id),
enabled: !!id,
staleTime: 15_000,
})
}
// ─── Create ──────────────────────────────────────────────────────────────────
export function useCreatePuntoDeVenta() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: CreatePuntoDeVentaRequest) => createPuntoDeVenta(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
},
})
}
// ─── Update ──────────────────────────────────────────────────────────────────
export function useUpdatePuntoDeVenta(id: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: UpdatePuntoDeVentaRequest) => updatePuntoDeVenta(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
},
})
}
// ─── Deactivate / Reactivate ─────────────────────────────────────────────────
export function useDeactivatePuntoDeVenta() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deactivatePuntoDeVenta(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
},
})
}
export function useReactivatePuntoDeVenta() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => reactivatePuntoDeVenta(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
},
})
}

View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { reservarNumero, getProximoNumero } from '../api/secuencias.api'
import type { TipoComprobante } from '../types'
// ─── Reservar ────────────────────────────────────────────────────────────────
export function useReservarNumero(puntoDeVentaId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (tipoComprobante: TipoComprobante) =>
reservarNumero(puntoDeVentaId, tipoComprobante),
onSuccess: (_data, tipoComprobante) => {
// Invalidate the proximo query for this pdv+tipo so it refetches
queryClient.invalidateQueries({
queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante],
})
},
})
}
// ─── Próximo número (read-only) ──────────────────────────────────────────────
export function useProximoNumero(puntoDeVentaId: number, tipoComprobante: TipoComprobante) {
return useQuery({
queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante],
queryFn: () => getProximoNumero(puntoDeVentaId, tipoComprobante),
enabled: !!puntoDeVentaId,
staleTime: 5_000,
})
}

View File

@@ -0,0 +1,49 @@
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm'
import { useCreatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm'
export function CreatePuntoDeVentaPage() {
const navigate = useNavigate()
const { mutate, isPending, error } = useCreatePuntoDeVenta()
function handleSubmit(values: PuntoDeVentaFormValues) {
mutate(
{
medioId: values.medioId,
numeroAFIP: values.numeroAFIP,
nombre: values.nombre,
},
{
onSuccess: () => {
toast.success('Punto de venta creado correctamente')
void navigate('/admin/puntos-de-venta')
},
},
)
}
return (
<div className="flex justify-center py-8">
<Card className="w-full max-w-lg">
<CardHeader className="space-y-1">
<CardTitle className="text-xl">Crear Punto de Venta</CardTitle>
<CardDescription>
Completá los datos para registrar un nuevo punto de venta AFIP.
</CardDescription>
</CardHeader>
<CardContent>
<PuntoDeVentaForm isPending={isPending} error={error} onSubmit={handleSubmit} />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm'
import { usePuntoDeVenta, useUpdatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
import { useMedio } from '../../medios/hooks/useMedio'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm'
export function EditPuntoDeVentaPage() {
const { id } = useParams<{ id: string }>()
const puntoDeVentaId = Number(id)
const navigate = useNavigate()
const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId)
const { mutate, isPending, error } = useUpdatePuntoDeVenta(puntoDeVentaId)
const { data: medio } = useMedio(pdv?.medioId ?? 0)
const medioInactivo = medio?.activo === false
function handleSubmit(values: PuntoDeVentaFormValues) {
mutate(
{
nombre: values.nombre,
numeroAFIP: values.numeroAFIP,
},
{
onSuccess: () => {
toast.success('Punto de venta actualizado correctamente')
void navigate(`/admin/puntos-de-venta/${puntoDeVentaId}`)
},
},
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!pdv) {
return (
<div className="py-12 text-center text-muted-foreground">
Punto de venta no encontrado.
</div>
)
}
return (
<div className="flex justify-center py-8">
<Card className="w-full max-w-lg">
<CardHeader className="space-y-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl">Editar Punto de Venta</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/admin/puntos-de-venta')}
>
Volver
</Button>
</div>
<CardDescription>
Editá los datos del punto de venta <strong>{pdv.nombre}</strong>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{medioInactivo && medio && (
<MedioInactivoBanner medioNombre={medio.nombre} />
)}
<PuntoDeVentaForm
initialData={pdv}
isPending={isPending || medioInactivo}
error={error}
onSubmit={handleSubmit}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,108 @@
import { useNavigate, useParams } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CanPerform } from '@/components/auth/CanPerform'
import { usePuntoDeVenta } from '../hooks/usePuntosDeVenta'
import { useMedio } from '../../medios/hooks/useMedio'
import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import { PdvInactivoBanner } from '../components/PdvInactivoBanner'
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function PuntoDeVentaDetailPage() {
const { id } = useParams<{ id: string }>()
const puntoDeVentaId = Number(id)
const navigate = useNavigate()
const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId)
const { data: medio } = useMedio(pdv?.medioId ?? 0)
const medioInactivo = medio?.activo === false
const pdvInactivo = pdv?.activo === false
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!pdv) {
return (
<div className="py-12 text-center text-muted-foreground">
Punto de venta no encontrado.
</div>
)
}
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{pdv.nombre}</h1>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/puntos-de-venta')}>
Volver
</Button>
</div>
<div className="rounded-md border border-border p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Número AFIP</span>
<span className="font-mono">{pdv.numeroAFIP}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Medio ID</span>
<span>{pdv.medioId}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estado</span>
{pdv.activo
? <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Activo</Badge>
: <Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Inactivo</Badge>
}
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Creado</span>
<span>{formatDate(pdv.fechaCreacion)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Modificado</span>
<span>{formatDate(pdv.fechaModificacion)}</span>
</div>
</div>
{pdvInactivo && (
<PdvInactivoBanner puntoDeVentaNombre={pdv.nombre} />
)}
{medioInactivo && medio && (
<MedioInactivoBanner medioNombre={medio.nombre} />
)}
<CanPerform permission="administracion:puntos_de_venta:gestionar">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={medioInactivo}
onClick={() => navigate(`/admin/puntos-de-venta/${puntoDeVentaId}/edit`)}
>
Editar
</Button>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={puntoDeVentaId}
puntoDeVentaNombre={pdv.nombre}
activo={pdv.activo}
disabled={medioInactivo}
/>
</div>
</CanPerform>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { CanPerform } from '@/components/auth/CanPerform'
import { PuntosDeVentaTable } from '../components/PuntosDeVentaTable'
import { PuntosDeVentaFilters } from '../components/PuntosDeVentaFilters'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import { usePuntosDeVentaList } from '../hooks/usePuntosDeVenta'
import { useMedio } from '../../medios/hooks/useMedio'
export function PuntosDeVentaListPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const [medioId, setMedioId] = useState<number | undefined>(undefined)
const [activo, setActivo] = useState<boolean | undefined>(undefined)
const query = {
page,
pageSize: 20,
...(medioId !== undefined ? { medioId } : {}),
...(activo !== undefined ? { activo } : {}),
}
const { data, isLoading } = usePuntosDeVentaList(query)
// Fetch parent medio only when filtering by a single medioId
const { data: filteredMedio } = useMedio(medioId ?? 0)
const medioInactivo = medioId !== undefined && filteredMedio?.activo === false
const handleMedioIdChange = useCallback((value: number | undefined) => {
setMedioId(value)
setPage(1)
}, [])
const handleActivoChange = useCallback((value: boolean | undefined) => {
setActivo(value)
setPage(1)
}, [])
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
const hasPrev = page > 1
const hasNext = page < totalPages
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Puntos de Venta</h1>
<CanPerform permission="administracion:puntos_de_venta:gestionar">
<Button
onClick={() => navigate('/admin/puntos-de-venta/nuevo')}
size="sm"
disabled={medioInactivo}
>
Nuevo punto de venta
</Button>
</CanPerform>
</div>
{medioInactivo && filteredMedio && (
<MedioInactivoBanner medioNombre={filteredMedio.nombre} />
)}
<PuntosDeVentaFilters
onMedioIdChange={handleMedioIdChange}
onActivoChange={handleActivoChange}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</div>
) : (
<PuntosDeVentaTable rows={data?.items ?? []} medioInactivo={medioInactivo} />
)}
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{data ? `${data.total} punto${data.total !== 1 ? 's' : ''} de venta` : ''}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
aria-label="Anterior"
>
Anterior
</Button>
<span className="flex items-center px-2 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
aria-label="Siguiente"
>
Siguiente
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
// ADM-008 — shared types for puntos-de-venta feature
export enum TipoComprobante {
FacturaA = 1,
FacturaB = 2,
FacturaC = 3,
NotaCreditoA = 4,
NotaCreditoB = 5,
NotaCreditoC = 6,
}
export interface PuntoDeVentaListItem {
id: number
medioId: number
numeroAFIP: number
nombre: string
activo: boolean
}
export interface PuntoDeVentaDetail {
id: number
medioId: number
numeroAFIP: number
nombre: string
activo: boolean
fechaCreacion: string
fechaModificacion: string | null
}
export interface PuntoDeVentaCreated {
id: number
medioId: number
numeroAFIP: number
nombre: string
activo: boolean
}
export interface CreatePuntoDeVentaRequest {
medioId: number
numeroAFIP: number
nombre: string
}
export interface UpdatePuntoDeVentaRequest {
nombre: string
numeroAFIP: number
}
export interface PuntosDeVentaQuery {
page?: number
pageSize?: number
medioId?: number
activo?: boolean
}
export interface ReservarNumeroResponse {
tipoComprobante: TipoComprobante
numeroReservado: number
}
export interface ProximoNumeroResponse {
tipoComprobante: TipoComprobante
proximoNumero: number
}
export interface PagedResult<T> {
items: T[]
page: number
pageSize: number
total: number
}

View File

@@ -21,6 +21,10 @@ import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage'
import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage'
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage'
import { PuntosDeVentaListPage } from './features/puntos-de-venta/pages/PuntosDeVentaListPage'
import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage'
import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage'
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -240,6 +244,40 @@ export function AppRoutes() {
}
/>
{/* Puntos de Venta routes */}
<Route
path="/admin/puntos-de-venta"
element={
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
<PuntosDeVentaListPage />
</ProtectedPage>
}
/>
<Route
path="/admin/puntos-de-venta/nuevo"
element={
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
<CreatePuntoDeVentaPage />
</ProtectedPage>
}
/>
<Route
path="/admin/puntos-de-venta/:id"
element={
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
<PuntoDeVentaDetailPage />
</ProtectedPage>
}
/>
<Route
path="/admin/puntos-de-venta/:id/edit"
element={
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
<EditPuntoDeVentaPage />
</ProtectedPage>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MedioInactivoBanner } from '../../../features/puntos-de-venta/components/MedioInactivoBanner'
import { PdvInactivoBanner } from '../../../features/puntos-de-venta/components/PdvInactivoBanner'
describe('MedioInactivoBanner', () => {
it('renders with medio nombre', () => {
render(<MedioInactivoBanner medioNombre="Diario El Día" />)
expect(screen.getByText(/medio desactivado/i)).toBeInTheDocument()
expect(screen.getByText(/diario el día/i)).toBeInTheDocument()
})
it('renders blocked operations message', () => {
render(<MedioInactivoBanner medioNombre="Radio AM" />)
expect(screen.getByText(/puntos de venta/i)).toBeInTheDocument()
})
})
describe('PdvInactivoBanner', () => {
it('renders with pdv nombre', () => {
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Central" />)
expect(screen.getByText(/punto de venta desactivado/i)).toBeInTheDocument()
expect(screen.getByText(/pdv central/i)).toBeInTheDocument()
})
it('renders reactivate hint', () => {
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Sur" />)
expect(screen.getByText(/reactivalo/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { DeactivatePuntoDeVentaModal } from '../../../features/puntos-de-venta/components/DeactivatePuntoDeVentaModal'
const API_URL = 'http://localhost:5000'
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderModal(activo = true) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={1}
puntoDeVentaNombre="PdV Central"
activo={activo}
/>
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('DeactivatePuntoDeVentaModal', () => {
it('shows "Desactivar" trigger when pdv is active', () => {
renderModal(true)
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
})
it('shows "Reactivar" trigger when pdv is inactive', () => {
renderModal(false)
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
})
it('opens dialog and shows confirmation text', async () => {
renderModal(true)
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
await waitFor(() =>
expect(screen.getByText(/desactivar punto de venta/i)).toBeInTheDocument(),
)
expect(screen.getByText(/pdv central/i)).toBeInTheDocument()
})
it('calls deactivate endpoint on confirm', async () => {
let called = false
server.use(
http.post(`${API_URL}/api/v1/admin/puntos-de-venta/1/deactivate`, () => {
called = true
return new HttpResponse(null, { status: 204 })
}),
)
renderModal(true)
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
await waitFor(() => screen.getByRole('alertdialog'))
await userEvent.click(screen.getByRole('button', { name: /desactivar$/i }))
await waitFor(() => expect(called).toBe(true))
})
it('is disabled when disabled prop is true', () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<DeactivatePuntoDeVentaModal
puntoDeVentaId={1}
puntoDeVentaNombre="PdV Central"
activo={true}
disabled={true}
/>
</MemoryRouter>
</QueryClientProvider>,
)
expect(screen.getByRole('button', { name: /desactivar/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { PuntoDeVentaForm } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm'
import type { PuntoDeVentaFormValues } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm'
import type { PuntoDeVentaDetail } from '../../../features/puntos-de-venta/types'
const API_URL = 'http://localhost:5000'
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const mockMedios = [
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
{ id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
]
const samplePdv: PuntoDeVentaDetail = {
id: 10,
medioId: 1,
numeroAFIP: 1,
nombre: 'PdV Central',
activo: true,
fechaCreacion: '2026-01-01T00:00:00Z',
fechaModificacion: null,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderForm(
opts: { initialData?: PuntoDeVentaDetail; onSubmit?: (v: PuntoDeVentaFormValues) => void } = {},
) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const onSubmit = opts.onSubmit ?? vi.fn()
server.use(
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }),
),
)
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<PuntoDeVentaForm
initialData={opts.initialData}
isPending={false}
error={null}
onSubmit={onSubmit}
/>
</MemoryRouter>
</QueryClientProvider>,
)
return { onSubmit }
}
describe('PuntoDeVentaForm — create mode', () => {
it('shows validation error when nombre is empty', async () => {
renderForm()
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
await waitFor(() =>
expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(),
)
})
it('shows validation error when numeroAFIP is 0 or negative', async () => {
renderForm()
const numeroInput = screen.getByLabelText(/número afip/i)
await userEvent.clear(numeroInput)
await userEvent.type(numeroInput, '0')
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
await waitFor(() =>
expect(screen.getByText(/mayor a 0/i)).toBeInTheDocument(),
)
})
it('calls onSubmit with correct values on valid form', async () => {
const onSubmit = vi.fn()
renderForm({ onSubmit })
// Select medio
const medioTrigger = screen.getByRole('combobox', { name: /medio/i })
await userEvent.click(medioTrigger)
await waitFor(() =>
expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(),
)
await userEvent.click(screen.getByRole('option', { name: 'Diario El Día' }))
// Fill numeroAFIP
const numeroInput = screen.getByLabelText(/número afip/i)
await userEvent.clear(numeroInput)
await userEvent.type(numeroInput, '3')
// Fill nombre
await userEvent.type(screen.getByLabelText(/nombre/i), 'PdV Norte')
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const firstArg = onSubmit.mock.calls[0][0]
expect(firstArg).toMatchObject({
medioId: 1,
numeroAFIP: 3,
nombre: 'PdV Norte',
})
})
})
})
describe('PuntoDeVentaForm — edit mode', () => {
it('medioId and numeroAFIP are disabled in edit mode', async () => {
renderForm({ initialData: samplePdv })
const medioTrigger = screen.getByRole('combobox', { name: /medio/i })
expect(medioTrigger).toBeDisabled()
const numeroInput = screen.getByLabelText(/número afip/i) as HTMLInputElement
expect(numeroInput.disabled).toBe(true)
})
it('pre-fills form with initialData values', async () => {
renderForm({ initialData: samplePdv })
await waitFor(() =>
expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('PdV Central'),
)
expect((screen.getByLabelText(/número afip/i) as HTMLInputElement).value).toBe('1')
})
it('shows "Guardar cambios" button in edit mode', () => {
renderForm({ initialData: samplePdv })
expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { PuntosDeVentaListPage } from '../../../features/puntos-de-venta/pages/PuntosDeVentaListPage'
import { useAuthStore } from '../../../stores/authStore'
const API_URL = 'http://localhost:5000'
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useNavigate: () => mockNavigate }
})
const adminWithPdv = {
id: 1,
username: 'admin',
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:puntos_de_venta:gestionar'],
mustChangePassword: false,
}
const userWithoutPdv = {
id: 2,
username: 'cajero',
nombre: 'Cajero',
rol: 'cajero',
permisos: [],
mustChangePassword: false,
}
const mockMedios = [
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
]
function makePdvs(n: number) {
return Array.from({ length: n }, (_, i) => ({
id: i + 1,
medioId: 1,
numeroAFIP: i + 1,
nombre: `PdV ${i + 1}`,
activo: true,
}))
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().clearAuth()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage(user = adminWithPdv) {
useAuthStore.setState({ user })
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/puntos-de-venta']}>
<Routes>
<Route path="/admin/puntos-de-venta" element={<PuntosDeVentaListPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('PuntosDeVentaListPage', () => {
it('renders rows when API returns items', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }),
),
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument())
expect(screen.getByText('PdV 2')).toBeInTheDocument()
expect(screen.getByText('PdV 3')).toBeInTheDocument()
})
it('shows empty state when items is empty', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
),
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(),
)
})
it('hides "Nuevo punto de venta" button when user lacks permission', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
),
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
),
)
renderPage(userWithoutPdv)
await waitFor(() =>
expect(screen.queryByRole('button', { name: /nuevo punto de venta/i })).not.toBeInTheDocument(),
)
})
it('shows "Nuevo punto de venta" button when user has permission', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
),
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByRole('button', { name: /nuevo punto de venta/i })).toBeInTheDocument(),
)
})
it('prev button disabled on first page', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }),
),
http.get(`${API_URL}/api/v1/admin/medios`, () =>
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument())
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,841 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Admin;
/// <summary>
/// ADM-008 B5 — Integration tests for /api/v1/admin/puntos-de-venta.
/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
/// Tests: T5.3 CRUD, T5.4 concurrencia, T5.5 secuencialidad.
/// </summary>
[Collection("ApiIntegration")]
public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string Endpoint = "/api/v1/admin/puntos-de-venta";
private const string MediosEndpoint = "/api/v1/admin/medios";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public PuntosDeVentaControllerTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = AdminUsername,
password = AdminPassword
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private async Task<string> GetCajeroTokenAsync(string username)
{
var adminToken = await GetAdminTokenAsync();
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
{
username,
password = "Secure1234!",
nombre = "Cajero",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var mkResp = await _client.SendAsync(mkUser);
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username,
password = "Secure1234!"
});
loginResp.EnsureSuccessStatusCode();
var loginJson = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
return loginJson.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
/// <summary>Creates a Medio via the API and returns its id.</summary>
private async Task<int> CreateMedioAsync(string codigo, string nombre, string token)
{
using var req = BuildRequest(HttpMethod.Post, MediosEndpoint, new
{
codigo,
nombre,
tipo = 1
}, token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("id").GetInt32();
}
/// <summary>Creates a PuntoDeVenta via the API and returns its id.</summary>
private async Task<int> CreatePdvAsync(int medioId, short numeroAFIP, string nombre, string token)
{
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId,
numeroAFIP,
nombre,
descripcion = (string?)null
}, token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("id").GetInt32();
}
private static async Task DeleteMedioIfExistsAsync(string codigo)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
var id = await conn.QuerySingleOrDefaultAsync<int?>(
"SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo });
if (id is null) return;
// Delete SecuenciaComprobante for PuntosDeVenta of this Medio (no versioning)
await conn.ExecuteAsync("""
DELETE sc FROM dbo.SecuenciaComprobante sc
INNER JOIN dbo.PuntoDeVenta pdv ON pdv.Id = sc.PuntoDeVentaId
WHERE pdv.MedioId = @id
""", new { id });
// Delete dependent PuntosDeVenta (disable versioning to also clear history)
await conn.ExecuteAsync("ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF)");
await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta_History WHERE MedioId = @id", new { id });
await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta WHERE MedioId = @id", new { id });
await conn.ExecuteAsync(
"ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PuntoDeVenta_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
// Delete dependent Secciones
await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)");
await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id });
await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id });
await conn.ExecuteAsync(
"ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
// Delete the medio itself
await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)");
await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id });
await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id });
await conn.ExecuteAsync(
"ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
}
private static async Task DeleteUsuarioIfExistsAsync(string username)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("""
DELETE rt FROM dbo.RefreshToken rt
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
WHERE u.Username = @Username
""", new { Username = username });
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
}
private static async Task<int> CountAuditEventsAsync(string action, string targetType, string targetId)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
return await conn.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId",
new { Action = action, TargetType = targetType, TargetId = targetId });
}
// ── 401 / 403 guards ─────────────────────────────────────────────────────
[Fact]
public async Task CreatePdv_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId = 1,
numeroAFIP = 1,
nombre = "PdV Test"
});
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task CreatePdv_WithCajeroRole_Returns403()
{
const string username = "adm008_pdv_cajero_403";
try
{
var token = await GetCajeroTokenAsync(username);
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId = 1,
numeroAFIP = 1,
nombre = "PdV Test 403"
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
// ── CREATE ────────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path: Create returns 201 + AuditEvent.</summary>
[Fact]
public async Task CreatePdv_WithAdmin_Returns201AndAuditEvent()
{
const string medioCodigo = "ADMS08_MED_C201";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Create 201", token);
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId,
numeroAFIP = (short)1,
nombre = "PdV Central Create",
descripcion = "Test desc"
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
Assert.NotNull(resp.Headers.Location);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("id", out var idEl));
var pdvId = idEl.GetInt32();
Assert.True(pdvId > 0);
Assert.Equal(medioId, json.GetProperty("medioId").GetInt32());
Assert.Equal(1, json.GetProperty("numeroAFIP").GetInt16());
Assert.Equal("PdV Central Create", json.GetProperty("nombre").GetString());
Assert.True(json.GetProperty("activo").GetBoolean());
var auditCount = await CountAuditEventsAsync("punto_de_venta.create", "PuntoDeVenta", pdvId.ToString());
Assert.Equal(1, auditCount);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al crear con Medio inactivo.</summary>
[Fact]
public async Task CreatePdv_WithInactiveMedio_Returns409MedioInactivo()
{
const string medioCodigo = "ADMS08_MED_INACT";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio Inactivo PDV", token);
// Deactivate the medio
using var deactReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token);
var deactResp = await _client.SendAsync(deactReq);
Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode);
// Try to create PdV in inactive medio
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId,
numeroAFIP = (short)1,
nombre = "PdV en Medio Inactivo"
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 numero_afip_duplicado al violar UNIQUE(MedioId, NumeroAFIP).</summary>
[Fact]
public async Task CreatePdv_DuplicateNumeroAFIPInSameMedio_Returns409()
{
const string medioCodigo = "ADMS08_MED_DUP";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Dup", token);
// First create
using var first = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId,
numeroAFIP = (short)1,
nombre = "PdV Original"
}, token);
var firstResp = await _client.SendAsync(first);
Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode);
// Second with same medioId + numeroAFIP
using var second = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId,
numeroAFIP = (short)1,
nombre = "PdV Duplicado"
}, token);
var secondResp = await _client.SendAsync(second);
Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode);
var json = await secondResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("numero_afip_duplicado", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — mismo NumeroAFIP en distinto Medio es permitido.</summary>
[Fact]
public async Task CreatePdv_SameNumeroAFIPDifferentMedio_Returns201()
{
const string medio1Codigo = "ADMS08_M1_MULTI";
const string medio2Codigo = "ADMS08_M2_MULTI";
var token = await GetAdminTokenAsync();
try
{
var medioId1 = await CreateMedioAsync(medio1Codigo, "Medio Multi 1 PDV", token);
var medioId2 = await CreateMedioAsync(medio2Codigo, "Medio Multi 2 PDV", token);
using var req1 = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId = medioId1,
numeroAFIP = (short)1,
nombre = "PdV Medio 1"
}, token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
// Same numeroAFIP in different medio → should succeed
using var req2 = BuildRequest(HttpMethod.Post, Endpoint, new
{
medioId = medioId2,
numeroAFIP = (short)1,
nombre = "PdV Medio 2"
}, token);
var resp2 = await _client.SendAsync(req2);
Assert.Equal(HttpStatusCode.Created, resp2.StatusCode);
}
finally
{
await DeleteMedioIfExistsAsync(medio1Codigo);
await DeleteMedioIfExistsAsync(medio2Codigo);
}
}
// ── GET BY ID ────────────────────────────────────────────────────────────
/// <summary>T5.3 — 404 cuando id inexistente.</summary>
[Fact]
public async Task GetPdvById_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("punto_de_venta_not_found", json.GetProperty("error").GetString());
}
// ── LIST ─────────────────────────────────────────────────────────────────
/// <summary>T5.3 — List returns 200 with paged result.</summary>
[Fact]
public async Task ListPdvs_WithAdmin_Returns200PagedResult()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'");
Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'");
}
/// <summary>T5.3 — List filtrado por medioId y activo.</summary>
[Fact]
public async Task ListPdvs_FilterByMedioAndActivo_ReturnsMatchingItems()
{
const string medioCodigo = "ADMS08_MED_LIST";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV List", token);
await CreatePdvAsync(medioId, 1, "PdV Lista 1", token);
await CreatePdvAsync(medioId, 2, "PdV Lista 2", token);
// Filter by medioId
using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}?medioId={medioId}&activo=true", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
var items = json.GetProperty("items").EnumerateArray().ToList();
Assert.Equal(2, items.Count);
Assert.All(items, item => Assert.Equal(medioId, item.GetProperty("medioId").GetInt32()));
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── UPDATE ────────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Update returns 200 + AuditEvent.</summary>
[Fact]
public async Task UpdatePdv_WithAdmin_Returns200AndAuditEvent()
{
const string medioCodigo = "ADMS08_MED_UPD";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Original", token);
using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new
{
nombre = "PdV Actualizado",
numeroAFIP = (short)2,
descripcion = "Nueva desc"
}, token);
var updateResp = await _client.SendAsync(updateReq);
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
var updated = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("PdV Actualizado", updated.GetProperty("nombre").GetString());
Assert.Equal(2, updated.GetProperty("numeroAFIP").GetInt16());
var auditCount = await CountAuditEventsAsync("punto_de_venta.update", "PuntoDeVenta", pdvId.ToString());
Assert.Equal(1, auditCount);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo.</summary>
[Fact]
public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo()
{
const string medioCodigo = "ADMS08_MED_UPDMI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update MedioInact", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Update Medio Inactivo", token);
// Deactivate the medio
using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token);
var deactMedioResp = await _client.SendAsync(deactMedioReq);
Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode);
// Try to update PdV with inactive medio
using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new
{
nombre = "PdV Medio Inactivo",
numeroAFIP = (short)1
}, token);
var updateResp = await _client.SendAsync(updateReq);
Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode);
var json = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── DEACTIVATE ────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Deactivate returns 204 + AuditEvent.</summary>
[Fact]
public async Task DeactivatePdv_WithAdmin_Returns204AndAuditEvent()
{
const string medioCodigo = "ADMS08_MED_DEACT";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Deactivate", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Desactivar", token);
using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
var deactResp = await _client.SendAsync(deactReq);
Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode);
var auditCount = await CountAuditEventsAsync("punto_de_venta.deactivate", "PuntoDeVenta", pdvId.ToString());
Assert.Equal(1, auditCount);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── REACTIVATE ────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Reactivate returns 204 + AuditEvent.</summary>
[Fact]
public async Task ReactivatePdv_WithAdmin_Returns204AndAuditEvent()
{
const string medioCodigo = "ADMS08_MED_REACT";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Reactivar", token);
// Deactivate first
using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
var deactResp = await _client.SendAsync(deactReq);
Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode);
// Reactivate
using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token);
var reactResp = await _client.SendAsync(reactReq);
Assert.Equal(HttpStatusCode.NoContent, reactResp.StatusCode);
var auditCount = await CountAuditEventsAsync("punto_de_venta.reactivate", "PuntoDeVenta", pdvId.ToString());
Assert.Equal(1, auditCount);
// Verify it's active again via GET
using var getReq = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}", bearerToken: token);
var getResp = await _client.SendAsync(getReq);
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
var pdvJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(pdvJson.GetProperty("activo").GetBoolean());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al reactivar con Medio inactivo.</summary>
[Fact]
public async Task ReactivatePdv_WhenMedioInactive_Returns409MedioInactivo()
{
const string medioCodigo = "ADMS08_MED_REACTMI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate Inactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reactivate Medio Inactivo", token);
// Deactivate PdV while medio is active
using var deactPdvReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
await _client.SendAsync(deactPdvReq);
// Deactivate medio
using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token);
var deactMedioResp = await _client.SendAsync(deactMedioReq);
Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode);
// Try to reactivate PdV with inactive medio
using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token);
var reactResp = await _client.SendAsync(reactReq);
Assert.Equal(HttpStatusCode.Conflict, reactResp.StatusCode);
var json = await reactResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── SECUENCIAS: RESERVAR ──────────────────────────────────────────────────
/// <summary>T5.3 — Primera reserva inicializa en 1.</summary>
[Fact]
public async Task ReservarNumero_FirstReservation_Returns1()
{
const string medioCodigo = "ADMS08_MED_RSV1";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar 1", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar First", token);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, json.GetProperty("numeroReservado").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 punto_de_venta_inactivo al reservar en PdV inactivo.</summary>
[Fact]
public async Task ReservarNumero_WhenPdvInactive_Returns409PdvInactivo()
{
const string medioCodigo = "ADMS08_MED_RSVI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar Inactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar Inactivo", token);
// Deactivate PdV
using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
await _client.SendAsync(deactReq);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al reservar con Medio inactivo.</summary>
[Fact]
public async Task ReservarNumero_WhenMedioInactive_Returns409MedioInactivo()
{
const string medioCodigo = "ADMS08_MED_RSVMI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar MedioInactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar MedioInact", token);
// Deactivate medio
using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token);
await _client.SendAsync(deactMedioReq);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── SECUENCIAS: PROXIMO ───────────────────────────────────────────────────
/// <summary>T5.3 — GetProximo es read-only: no modifica UltimoNumero.</summary>
[Fact]
public async Task GetProximoNumero_DoesNotChangeState()
{
const string medioCodigo = "ADMS08_MED_PROX";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo", token);
// Reserve once to establish state
using var rsv = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
await _client.SendAsync(rsv);
// GetProximo twice — should return 2 both times
using var req1 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.OK, resp1.StatusCode);
var json1 = await resp1.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(2, json1.GetProperty("proximoNumero").GetInt32());
using var req2 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token);
var resp2 = await _client.SendAsync(req2);
var json2 = await resp2.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(2, json2.GetProperty("proximoNumero").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — GetProximo para fila inexistente devuelve 1.</summary>
[Fact]
public async Task GetProximoNumero_WhenNoSequenceExists_Returns1()
{
const string medioCodigo = "ADMS08_MED_PROX1";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo 1", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo First", token);
using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaB/proximo", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, json.GetProperty("proximoNumero").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── T5.4 — Concurrencia ───────────────────────────────────────────────────
/// <summary>
/// T5.4 — 50 tasks paralelas reservando para mismo PdV + TipoComprobante
/// deben producir 50 números distintos cubriendo {1..50}.
/// </summary>
[Fact]
public async Task ReservarNumero_50ConcurrentReservations_ProducesNoDuplicates()
{
const string medioCodigo = "ADMS08_CONC50";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Concurrencia 50", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Concurrencia 50", token);
const int taskCount = 50;
var tasks = Enumerable.Range(0, taskCount)
.Select(_ => Task.Run(async () =>
{
// Each task creates its own HttpClient to avoid sharing
// the shared _client which is not thread-safe for concurrent requests.
// Use BuildRequest on a shared client IS safe since HttpClient is thread-safe
// for concurrent operations as long as each message is distinct.
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("numeroReservado").GetInt32();
}))
.ToList();
var results = await Task.WhenAll(tasks);
// All 50 numbers must be present exactly once
Assert.Equal(taskCount, results.Length);
Assert.Equal(taskCount, results.Distinct().Count());
var expected = Enumerable.Range(1, taskCount).ToHashSet();
var actual = results.ToHashSet();
Assert.Equal(expected, actual);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── T5.5 — Secuencialidad ─────────────────────────────────────────────────
/// <summary>
/// T5.5 — 100 reservas en serie para mismo PdV + TipoComprobante
/// deben devolver {1, 2, 3, ..., 100} en orden.
/// </summary>
[Fact]
public async Task ReservarNumero_100SerialReservations_ProducesSequentialNumbers()
{
const string medioCodigo = "ADMS08_SEQ100";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Secuencial 100", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Secuencial 100", token);
const int count = 100;
var results = new List<int>(count);
for (int i = 0; i < count; i++)
{
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
results.Add(json.GetProperty("numeroReservado").GetInt32());
}
// Verify sequential: {1, 2, 3, ..., 100}
var expected = Enumerable.Range(1, count).ToList();
Assert.Equal(expected, results);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
}

View File

@@ -47,8 +47,9 @@ public class AuthControllerTests
Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty");
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, permisos.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, permisos.GetArrayLength());
}
// Scenario: invalid credentials return 401 with opaque error

View File

@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact]
public async Task GetPermisos_WithAdmin_Returns200With22Items()
public async Task GetPermisos_WithAdmin_Returns200With23Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
@@ -138,8 +138,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, list.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, list.GetArrayLength());
}
[Fact]
@@ -182,7 +183,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task GetRolPermisos_AdminRol_Returns200With22Items()
public async Task GetRolPermisos_AdminRol_Returns200With23Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
@@ -190,8 +191,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
Assert.Equal(22, list.GetArrayLength());
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
Assert.Equal(23, list.GetArrayLength());
}
[Fact]

View File

@@ -44,6 +44,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -79,8 +79,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
var list = await _repository.ListAsync();
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
// + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total
Assert.Equal(22, list.Count);
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total
Assert.Equal(23, list.Count);
}
[Fact]

View File

@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
{
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' = 23 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(22, permisos.Count);
Assert.Equal(23, permisos.Count);
}
[Fact]

View File

@@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -40,6 +40,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -39,6 +39,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -42,6 +42,8 @@ public class MedioRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -0,0 +1,122 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Create;
public class CreatePuntoDeVentaCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly CreatePuntoDeVentaCommandHandler _handler;
private static Medio MakeMedio(int id = 5, bool activo = true)
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
private static CreatePuntoDeVentaCommand ValidCommand() => new(
MedioId: 5,
NumeroAFIP: 1,
Nombre: "PdV Central",
Descripcion: null);
public CreatePuntoDeVentaCommandHandlerTests()
{
_handler = new CreatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5));
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
_repo.AddAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>()).Returns(10);
}
// ── medio not found → throws ─────────────────────────────────────────────
[Fact]
public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException()
{
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns((Medio?)null);
await Assert.ThrowsAsync<MedioNotFoundException>(
() => _handler.Handle(ValidCommand()));
}
// ── medio inactivo → throws ──────────────────────────────────────────────
[Fact]
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
{
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
await Assert.ThrowsAsync<MedioInactivoException>(
() => _handler.Handle(ValidCommand()));
}
// ── NumeroAFIP duplicado → throws ────────────────────────────────────────
[Fact]
public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException()
{
_repo.ExistsByNumeroAFIPInMedioAsync(5, 1, null, Arg.Any<CancellationToken>()).Returns(true);
await Assert.ThrowsAsync<NumeroAFIPDuplicadoException>(
() => _handler.Handle(ValidCommand()));
}
[Fact]
public async Task Handle_NumeroAFIPDuplicado_DoesNotCallAddAsync()
{
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
try { await _handler.Handle(ValidCommand()); } catch (NumeroAFIPDuplicadoException) { }
await _repo.DidNotReceive().AddAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository()
{
var result = await _handler.Handle(ValidCommand());
Assert.Equal(10, result.Id);
}
[Fact]
public async Task Handle_HappyPath_DtoContainsCorrectFields()
{
var result = await _handler.Handle(ValidCommand());
Assert.Equal(5, result.MedioId);
Assert.Equal(1, result.NumeroAFIP);
Assert.Equal("PdV Central", result.Nombre);
Assert.True(result.Activo);
}
[Fact]
public async Task Handle_HappyPath_CallsAuditWithCreateAction()
{
await _handler.Handle(ValidCommand());
await _audit.Received(1).LogAsync(
action: "punto_de_venta.create",
targetType: "PuntoDeVenta",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── audit fail-closed ────────────────────────────────────────────────────
[Fact]
public async Task Handle_AuditLoggerThrows_ExceptionBubblesUpAndAddNotCommitted()
{
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("audit fail")));
await Assert.ThrowsAsync<InvalidOperationException>(
() => _handler.Handle(ValidCommand()));
}
}

View File

@@ -0,0 +1,86 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Deactivate;
public class DeactivatePuntoDeVentaCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly DeactivatePuntoDeVentaCommandHandler _handler;
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true)
=> new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null);
private static Medio MakeMedio(int id = 5, bool activo = true)
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
public DeactivatePuntoDeVentaCommandHandlerTests()
{
_handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
_medioRepo.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(MakeMedio(5, true));
}
[Fact]
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(new DeactivatePuntoDeVentaCommand(999)));
}
[Fact]
public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
await _repo.DidNotReceive().UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ActivePdv_CallsUpdateAsyncWithInactiveEntity()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
await _repo.Received(1).UpdateAsync(
Arg.Is<PuntoDeVenta>(p => !p.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ActivePdv_WritesAuditWithDeactivateAction()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
await _audit.Received(1).LogAsync(
action: "punto_de_venta.deactivate",
targetType: "PuntoDeVenta",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,46 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.GetById;
public class GetPuntoDeVentaByIdQueryHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly GetPuntoDeVentaByIdQueryHandler _handler;
public GetPuntoDeVentaByIdQueryHandlerTests()
{
_handler = new GetPuntoDeVentaByIdQueryHandler(_repo);
}
private static PuntoDeVenta MakePdv(int id = 5) =>
new(id, 2, 3, "PdV " + id, "Desc", true, DateTime.UtcNow, null);
[Fact]
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(new GetPuntoDeVentaByIdQuery(999)));
}
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields()
{
var pdv = MakePdv(5);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(pdv);
var result = await _handler.Handle(new GetPuntoDeVentaByIdQuery(5));
Assert.Equal(5, result.Id);
Assert.Equal(2, result.MedioId);
Assert.Equal(3, result.NumeroAFIP);
Assert.Equal("PdV 5", result.Nombre);
Assert.Equal("Desc", result.Descripcion);
Assert.True(result.Activo);
}
}

View File

@@ -0,0 +1,76 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.PuntosDeVenta.List;
public class ListPuntosDeVentaQueryHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly ListPuntosDeVentaQueryHandler _handler;
public ListPuntosDeVentaQueryHandlerTests()
{
_handler = new ListPuntosDeVentaQueryHandler(_repo);
}
private static PuntoDeVenta MakePdv(int id) =>
new(id, 5, (short)id, "PdV " + id, null, true, DateTime.UtcNow, null);
[Fact]
public async Task Handle_ReturnsPagedDtoItems()
{
var items = new List<PuntoDeVenta> { MakePdv(1), MakePdv(2) };
var pagedResult = new PagedResult<PuntoDeVenta>(items, 1, 20, 2);
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
.Returns(pagedResult);
var result = await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, null, null));
Assert.Equal(2, result.Total);
Assert.Equal(2, result.Items.Count);
Assert.Equal(1, result.Items[0].NumeroAFIP);
}
[Fact]
public async Task Handle_ClampsPageSizeToMax100()
{
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<PuntoDeVenta>([], 1, 100, 0));
await _handler.Handle(new ListPuntosDeVentaQuery(1, 500, null, null));
await _repo.Received(1).GetPagedAsync(
Arg.Is<PuntosDeVentaQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ClampsPageToMin1()
{
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<PuntoDeVenta>([], 1, 20, 0));
await _handler.Handle(new ListPuntosDeVentaQuery(0, 20, null, null));
await _repo.Received(1).GetPagedAsync(
Arg.Is<PuntosDeVentaQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_FiltersByMedioId()
{
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<PuntoDeVenta>([], 1, 20, 0));
await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, MedioId: 5, null));
await _repo.Received(1).GetPagedAsync(
Arg.Is<PuntosDeVentaQuery>(q => q.MedioId == 5),
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,52 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.PuntosDeVenta.ProximoNumero;
public class GetProximoNumeroQueryHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly GetProximoNumeroQueryHandler _handler;
public GetProximoNumeroQueryHandlerTests()
{
_handler = new GetProximoNumeroQueryHandler(_repo);
}
[Fact]
public async Task Handle_ExistingSequence_ReturnsUltimoNumeroMasUno()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(7);
var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA));
Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante);
Assert.Equal(8, result.ProximoNumero);
}
[Fact]
public async Task Handle_NoExistingSequence_ReturnsOne()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaB, Arg.Any<CancellationToken>())
.Returns((int?)null);
var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaB));
Assert.Equal(1, result.ProximoNumero);
}
[Fact]
public async Task Handle_DoesNotCallReservarNumero()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(5);
await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA));
await _repo.DidNotReceive().ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,98 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Reactivate;
public class ReactivatePuntoDeVentaCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly ReactivatePuntoDeVentaCommandHandler _handler;
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = false)
=> new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null);
private static Medio MakeMedio(int id = 5, bool activo = true)
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
public ReactivatePuntoDeVentaCommandHandlerTests()
{
_handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
_medioRepo.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(MakeMedio(5, true));
}
[Fact]
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(new ReactivatePuntoDeVentaCommand(999)));
}
[Fact]
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
await Assert.ThrowsAsync<MedioInactivoException>(
() => _handler.Handle(new ReactivatePuntoDeVentaCommand(10)));
}
[Fact]
public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
await _repo.DidNotReceive().UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_InactivePdv_CallsUpdateAsyncWithActiveEntity()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
await _repo.Received(1).UpdateAsync(
Arg.Is<PuntoDeVenta>(p => p.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_InactivePdv_WritesAuditWithReactivateAction()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
await _audit.Received(1).LogAsync(
action: "punto_de_venta.reactivate",
targetType: "PuntoDeVenta",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MedioInactivo_NoAuditLogged()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
try { await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); } catch (MedioInactivoException) { }
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,126 @@
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Domain.Enums;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar;
public class ReservarNumeroCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly ReservarNumeroCommandHandler _handler;
private static readonly ReservarNumeroCommand ValidCommand =
new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA);
public ReservarNumeroCommandHandlerTests()
{
// Use delay = 0 for fast tests
_handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]);
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_ReturnsNumeroReservado()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(7);
var result = await _handler.Handle(ValidCommand);
Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante);
Assert.Equal(7, result.NumeroReservado);
}
// ── retry deadlock ────────────────────────────────────────────────────────
[Fact]
public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult()
{
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock),
_ => Task.FromResult(3));
var result = await _handler.Handle(ValidCommand);
Assert.Equal(3, result.NumeroReservado);
}
[Fact]
public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException()
{
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock));
await Assert.ThrowsAsync<DeadlockTransientException>(
() => _handler.Handle(ValidCommand));
}
[Fact]
public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal()
{
// backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries)
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<int>(deadlock));
try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { }
await _repo.Received(4).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
// ── domain exceptions bubble up without retry ─────────────────────────────
[Fact]
public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new PuntoDeVentaInactivoException(10));
await Assert.ThrowsAsync<PuntoDeVentaInactivoException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MedioInactivo_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new MedioInactivoException(5));
await Assert.ThrowsAsync<MedioInactivoException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PdvNotFound_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new PuntoDeVentaNotFoundException(10));
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,103 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Update;
public class UpdatePuntoDeVentaCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly UpdatePuntoDeVentaCommandHandler _handler;
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true)
=> new(id, medioId, 1, "Original", null, activo, DateTime.UtcNow, null);
private static Medio MakeMedio(int id = 5, bool activo = true)
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
private static UpdatePuntoDeVentaCommand ValidCommand(int id = 10) =>
new(Id: id, Nombre: "Nuevo Nombre", NumeroAFIP: 3, Descripcion: null);
public UpdatePuntoDeVentaCommandHandlerTests()
{
_handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10));
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5));
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
}
[Fact]
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(new UpdatePuntoDeVentaCommand(999, "X", 1, null)));
}
[Fact]
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
{
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
await Assert.ThrowsAsync<MedioInactivoException>(
() => _handler.Handle(ValidCommand()));
}
[Fact]
public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException()
{
_repo.ExistsByNumeroAFIPInMedioAsync(5, 3, 10, Arg.Any<CancellationToken>()).Returns(true);
await Assert.ThrowsAsync<NumeroAFIPDuplicadoException>(
() => _handler.Handle(ValidCommand()));
}
[Fact]
public async Task Handle_HappyPath_CallsUpdateAsyncOnce()
{
await _handler.Handle(ValidCommand());
await _repo.Received(1).UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields()
{
var result = await _handler.Handle(ValidCommand());
Assert.Equal(10, result.Id);
Assert.Equal("Nuevo Nombre", result.Nombre);
Assert.Equal(3, result.NumeroAFIP);
}
[Fact]
public async Task Handle_HappyPath_CallsAuditWithUpdateAction()
{
await _handler.Handle(ValidCommand());
await _audit.Received(1).LogAsync(
action: "punto_de_venta.update",
targetType: "PuntoDeVenta",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MedioInactivo_NoAuditLogged()
{
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
try { await _handler.Handle(ValidCommand()); } catch (MedioInactivoException) { }
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -44,6 +44,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});

View File

@@ -39,6 +39,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
await EnsureV011SchemaAsync();
// V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante.
await EnsureV013SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -56,6 +59,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
]
});
@@ -165,7 +169,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'),
-- V011 (ADM-001): permiso para CRUD de Secciones
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion')
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'),
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta y reservar numeros','administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -207,6 +213,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:permisos:ver'),
-- V011 (ADM-001)
('admin', 'administracion:secciones:gestionar'),
-- V013 (ADM-008)
('admin', 'administracion:puntos_de_venta:gestionar'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
@@ -373,6 +381,201 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// ADM-008 (V013): applies PuntoDeVenta / SecuenciaComprobante schema + temporal tables +
/// permiso 'administracion:puntos_de_venta:gestionar' + SP usp_ReservarNumeroComprobante.
/// Idempotent — mirrors V013__create_puntos_de_venta.sql. Permiso y asignación se siembran
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
/// </summary>
private async Task EnsureV013SchemaAsync()
{
const string createPdv = """
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
BEGIN
CREATE TABLE dbo.PuntoDeVenta (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY,
MedioId INT NOT NULL,
NumeroAFIP SMALLINT NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Descripcion NVARCHAR(255) NULL,
Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP),
CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1)
);
END
""";
const string createPdvIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta'))
BEGIN
CREATE INDEX IX_PuntoDeVenta_MedioId_Activo
ON dbo.PuntoDeVenta(MedioId, Activo)
INCLUDE (NumeroAFIP, Nombre);
END
""";
const string createSecuencia = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecuenciaComprobante (
PuntoDeVentaId INT NOT NULL,
TipoComprobante TINYINT NOT NULL,
UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante),
CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION,
CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6),
CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0)
);
END
""";
const string addPdvPeriod = """
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.PuntoDeVenta
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
END
""";
const string setPdvVersioning = """
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.PuntoDeVenta
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.PuntoDeVenta_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
END
""";
// SecuenciaComprobante: sin SYSTEM_VERSIONING (AD8 revisitado — ver comentario en V013).
// Si una version previa del fixture activo SYSTEM_VERSIONING, lo desactiva + drop history.
const string disableSecuenciaVersioning = """
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
END
""";
const string dropSecuenciaHistory = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
END
""";
const string dropSecuenciaPeriod = """
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
END
""";
const string dropSecuenciaValidCols = """
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
END
""";
const string dropSp = """
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
""";
const string createSp = """
CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante
@PuntoDeVentaId INT,
@TipoComprobante TINYINT,
@NumeroReservado INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN;
DECLARE @PdvActivo BIT;
DECLARE @MedioActivo BIT;
SELECT
@PdvActivo = p.Activo,
@MedioActivo = m.Activo
FROM dbo.PuntoDeVenta p
JOIN dbo.Medio m ON m.Id = p.MedioId
WHERE p.Id = @PuntoDeVentaId;
IF @PdvActivo IS NULL
BEGIN
ROLLBACK;
THROW 50003, 'punto_de_venta_not_found', 1;
END
IF @PdvActivo = 0
BEGIN
ROLLBACK;
THROW 50001, 'punto_de_venta_inactivo', 1;
END
IF @MedioActivo = 0
BEGIN
ROLLBACK;
THROW 50002, 'medio_inactivo', 1;
END
DECLARE @_out TABLE (n INT NOT NULL);
UPDATE dbo.SecuenciaComprobante
SET
UltimoNumero = UltimoNumero + 1,
FechaModificacion = SYSUTCDATETIME()
OUTPUT inserted.UltimoNumero INTO @_out(n)
WHERE PuntoDeVentaId = @PuntoDeVentaId
AND TipoComprobante = @TipoComprobante;
IF @@ROWCOUNT = 0
BEGIN
INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero)
VALUES (@PuntoDeVentaId, @TipoComprobante, 1);
SET @NumeroReservado = 1;
END
ELSE
BEGIN
SELECT @NumeroReservado = n FROM @_out;
END
COMMIT;
END
""";
await _connection.ExecuteAsync(createPdv);
await _connection.ExecuteAsync(createPdvIndex);
await _connection.ExecuteAsync(createSecuencia);
await _connection.ExecuteAsync(addPdvPeriod);
await _connection.ExecuteAsync(setPdvVersioning);
await _connection.ExecuteAsync(disableSecuenciaVersioning);
await _connection.ExecuteAsync(dropSecuenciaHistory);
await _connection.ExecuteAsync(dropSecuenciaPeriod);
await _connection.ExecuteAsync(dropSecuenciaValidCols);
await _connection.ExecuteAsync(dropSp);
await _connection.ExecuteAsync(createSp);
}
/// <summary>
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
/// </summary>