Compare commits
10 Commits
43877bd4a1
...
4368c42599
| Author | SHA1 | Date | |
|---|---|---|---|
| 4368c42599 | |||
| 65787db272 | |||
| 4720f6772f | |||
| 056045232c | |||
| 4b96cdefcc | |||
| d61292afa4 | |||
| 48779543f9 | |||
| 39160bbb83 | |||
| 489359f0b8 | |||
| 50f6f2b67a |
1
.vite/vitest/results.json
Normal file
1
.vite/vitest/results.json
Normal 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}]]}
|
||||
@@ -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
|
||||
|
||||
|
||||
206
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
206
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal file
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal 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
|
||||
);
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Create;
|
||||
|
||||
public sealed record CreatePuntoDeVentaCommand(
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
string? Descripcion);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
|
||||
public sealed record DeactivatePuntoDeVentaCommand(int Id);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
|
||||
public sealed record PuntoDeVentaStatusDto(int Id, short NumeroAFIP, bool Activo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
|
||||
public sealed record GetPuntoDeVentaByIdQuery(int Id);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.List;
|
||||
|
||||
public sealed record ListPuntosDeVentaQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
int? MedioId,
|
||||
bool? Activo);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.List;
|
||||
|
||||
public sealed record PuntoDeVentaListItemDto(
|
||||
int Id,
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
bool Activo);
|
||||
@@ -0,0 +1,5 @@
|
||||
using SIGCM2.Domain.Enums;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
|
||||
|
||||
public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using SIGCM2.Domain.Enums;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
|
||||
|
||||
public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||
|
||||
public sealed record ReactivatePuntoDeVentaCommand(int Id);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using SIGCM2.Domain.Enums;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
|
||||
|
||||
public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado);
|
||||
@@ -0,0 +1,5 @@
|
||||
using SIGCM2.Domain.Enums;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
|
||||
|
||||
public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Update;
|
||||
|
||||
public sealed record UpdatePuntoDeVentaCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
short NumeroAFIP,
|
||||
string? Descripcion);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
22
src/web/src/features/puntos-de-venta/api/secuencias.api.ts
Normal file
22
src/web/src/features/puntos-de-venta/api/secuencias.api.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{medioNombre}" 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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{puntoDeVentaNombre}" está desactivado. Reactivalo para habilitar
|
||||
nuevamente sus operaciones.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
71
src/web/src/features/puntos-de-venta/types.ts
Normal file
71
src/web/src/features/puntos-de-venta/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
30
src/web/src/tests/features/puntos-de-venta/Banners.test.tsx
Normal file
30
src/web/src/tests/features/puntos-de-venta/Banners.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
841
tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs
Normal file
841
tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user