ADM-008: Puntos de Venta (CRUD fundacional) #19

Merged
dmolinari merged 18 commits from feature/ADM-008 into main 2026-04-17 17:31:21 +00:00
70 changed files with 4144 additions and 13 deletions

View File

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

View File

@@ -0,0 +1,81 @@
-- V013_ROLLBACK.sql
-- Reversa de V013__create_puntos_de_venta.sql.
--
-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, su historia temporal,
-- el permiso 'administracion:puntos_de_venta:gestionar' y sus asignaciones.
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001).
-- Si FAC-001 ya está aplicado, este rollback fallará — usar backup.
--
-- NOTA: SecuenciaComprobante y SP usp_ReservarNumeroComprobante ya no forman parte
-- de V013 (eliminados en cirugía post-smoke Batch 9). Este rollback solo maneja PuntoDeVenta.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF 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 = OFF);
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta'))
BEGIN
ALTER TABLE dbo.PuntoDeVenta DROP PERIOD FOR SYSTEM_TIME;
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidFrom;
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidTo;
ALTER TABLE dbo.PuntoDeVenta DROP COLUMN ValidFrom, ValidTo;
PRINT 'PuntoDeVenta: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.PuntoDeVenta_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.PuntoDeVenta_History;
PRINT 'PuntoDeVenta_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop tabla PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.PuntoDeVenta;
PRINT 'Table dbo.PuntoDeVenta dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'administracion:puntos_de_venta:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'administracion:puntos_de_venta:gestionar';
GO
PRINT '';
PRINT 'V013 rolled back. dbo.PuntoDeVenta and its history removed.';
PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.';
GO

View File

@@ -0,0 +1,179 @@
-- V013__create_puntos_de_venta.sql
-- ADM-008 Puntos de Venta: DDL para dbo.PuntoDeVenta + permiso AFIP.
--
-- NOTA POST-SMOKE (Batch 9): SecuenciaComprobante, SP usp_ReservarNumeroComprobante
-- y TipoComprobante fueron eliminados. SIG-CM2.0 NO genera números AFIP — IMAC
-- (Plataforma Infogestión) los asigna externamente. Un worker futuro (INT-001)
-- polleará la vista de Infogestión para asociar NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI.
-- PuntoDeVenta.NumeroAFIP es config fija que se manda en el payload a IMAC.
--
-- Cambios:
-- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y).
-- 2. Drops idempotentes de artefactos de versión previa (SecuenciaComprobante + SP).
-- 3. Permiso 'administracion:puntos_de_venta:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V013_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- NOTA: el código de permiso usa guion_bajo (_) según CK_Permiso_Codigo_Format del proyecto.
-- Código efectivo: 'administracion:puntos_de_venta:gestionar'
-- El spec menciona 'administracion:puntos-de-venta:gestionar' (guion) pero el CHECK constraint
-- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente.
-- El backend y frontend deben usar el código con guion_bajo.
--
-- Covers: REQ-PDV-001, -003, -009
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008
--
-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta.
-- Cada instalación configura sus propios PdVs con el NumeroAFIP real asignado por AFIP/ARCA.
-- Seedear con valores ficticios generaría confusión operativa. El admin los crea manualmente.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 0. Drops idempotentes de artefactos de versión previa
-- (SecuenciaComprobante + SP — eliminados en cirugía post-smoke Batch 9)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped (cleanup).';
END
GO
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);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History dropped (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante;
PRINT 'Table dbo.SecuenciaComprobante dropped (cleanup).';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
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)
);
PRINT 'Table dbo.PuntoDeVenta created.';
END
ELSE
PRINT 'Table dbo.PuntoDeVenta already exists — skip.';
GO
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);
PRINT 'Index IX_PuntoDeVenta_MedioId_Activo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
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);
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME added.';
END
GO
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
));
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING = ON (history: dbo.PuntoDeVenta_History, retention: 10 years).';
END
ELSE
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'PuntoDeVenta_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 = 'PuntoDeVenta_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.PuntoDeVenta_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'PuntoDeVenta_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Permiso: administracion:puntos_de_venta:gestionar
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:puntos_de_venta:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V013 applied successfully.';
PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)';
PRINT ' - Permiso administracion:puntos_de_venta:gestionar (asignado a admin)';
PRINT ' - Artefactos de version previa (SecuenciaComprobante + SP) eliminados si existian';
GO

View File

@@ -0,0 +1,175 @@
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.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Update;
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();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-008: Create punto de venta request body.</summary>
public sealed record CreatePuntoDeVentaRequest(
int? MedioId,
short? NumeroAFIP,
string? Nombre,
string? Descripcion);
/// <summary>ADM-008: Update punto de venta request body.</summary>
public sealed record UpdatePuntoDeVentaRequest(
string? Nombre,
short? NumeroAFIP,
string? Descripcion);

View File

@@ -231,6 +231,31 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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 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 // UDT-009: permiso override validation errors
case InvalidPermisoCodesException ipce: case InvalidPermisoCodesException ipce:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails

View File

@@ -0,0 +1,13 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
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);
}

View File

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

View File

@@ -21,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update; 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.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Application.Secciones.Create; using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate; using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById; using SIGCM2.Application.Secciones.GetById;
@@ -90,6 +96,14 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>(); services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>(); 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>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Punto de Venta AFIP vinculado a un Medio. Gestiona comprobantes fiscales.
/// Identidad por Id (IDENTITY). NumeroAFIP único por Medio (enforced por UNIQUE(MedioId,NumeroAFIP) en BD).
/// </summary>
public sealed class PuntoDeVenta
{
public int Id { get; }
public int MedioId { get; }
public short NumeroAFIP { get; }
public string Nombre { get; }
public string? Descripcion { get; }
public bool Activo { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
public PuntoDeVenta(
int id,
int medioId,
short numeroAFIP,
string nombre,
string? descripcion,
bool activo,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
MedioId = medioId;
NumeroAFIP = numeroAFIP;
Nombre = nombre;
Descripcion = descripcion;
Activo = activo;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory para crear un nuevo PdV (Id=0 — BD asigna via IDENTITY; Activo=true; FechaCreacion por DF de BD).
/// </summary>
public static PuntoDeVenta ForCreation(int medioId, short numeroAFIP, string nombre, string? descripcion)
=> new(
id: 0,
medioId: medioId,
numeroAFIP: numeroAFIP,
nombre: nombre,
descripcion: descripcion,
activo: true,
fechaCreacion: default,
fechaModificacion: null);
/// <summary>
/// Retorna una nueva instancia con nombre, numeroAFIP y descripcion actualizados.
/// MedioId es inmutable (enforce en BD).
/// </summary>
public PuntoDeVenta WithUpdatedProfile(string nombre, short numeroAFIP, string? descripcion)
=> new(
id: Id,
medioId: MedioId,
numeroAFIP: numeroAFIP,
nombre: nombre,
descripcion: descripcion,
activo: Activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
public PuntoDeVenta WithActivo(bool activo)
=> new(
id: Id,
medioId: MedioId,
numeroAFIP: NumeroAFIP,
nombre: Nombre,
descripcion: Descripcion,
activo: activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
}

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a (MedioId, NumeroAFIP) combination already exists in the system.
/// Enforced by UNIQUE(MedioId, NumeroAFIP) in DB as safety net (REQ-PDV-003).
/// </summary>
public sealed class NumeroAFIPDuplicadoException : DomainException
{
public int MedioId { get; }
public short NumeroAFIP { get; }
public NumeroAFIPDuplicadoException(int medioId, short numeroAFIP)
: base($"El número AFIP '{numeroAFIP}' ya existe para el medio {medioId}.")
{
MedioId = medioId;
NumeroAFIP = numeroAFIP;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested PuntoDeVenta does not exist in the system.
/// </summary>
public sealed class PuntoDeVentaNotFoundException : DomainException
{
public int Id { get; }
public PuntoDeVentaNotFoundException(int id)
: base($"El punto de venta con id '{id}' no existe.")
{
Id = id;
}
}

View File

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

View File

@@ -0,0 +1,203 @@
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.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);
}
// ── mapping ───────────────────────────────────────────────────────────────
private static PuntoDeVenta MapRow(PdvRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
numeroAFIP: r.NumeroAFIP,
nombre: r.Nombre,
descripcion: r.Descripcion,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static PuntoDeVenta MapRow(PdvPagedRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
numeroAFIP: r.NumeroAFIP,
nombre: r.Nombre,
descripcion: r.Descripcion,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static bool IsUniqueViolation(SqlException ex)
=> ex.Number is 2627 or 2601;
// ── private rows ──────────────────────────────────────────────────────────
private sealed record PdvRow(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record PdvPagedRow(
int Id,
int MedioId,
short NumeroAFIP,
string Nombre,
string? Descripcion,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
// ADM-008 — shared types for puntos-de-venta feature
// NOTE: numeración AFIP (NumeroFactura, CAI) es asignada externamente por IMAC/Infogestión.
// Un worker futuro (INT-001) polleará la vista de Infogestión para asociar
// NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI. No se generan números aquí.
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 PagedResult<T> {
items: T[]
page: number
pageSize: number
total: number
}

View File

@@ -21,6 +21,10 @@ import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage'
import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage' import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage'
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage' import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage' 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 { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout' import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout' 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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,600 @@
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 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);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
public class PuntoDeVentaTests
{
private static PuntoDeVenta MakePdv(
int id = 1,
int medioId = 5,
short numeroAFIP = 1,
string nombre = "PdV Central",
string? descripcion = null,
bool activo = true)
=> new(id, medioId, numeroAFIP, nombre, descripcion, activo, DateTime.UtcNow, null);
// ── ForCreation ───────────────────────────────────────────────────────────
[Fact]
public void ForCreation_SetsCorrectValues()
{
var pdv = PuntoDeVenta.ForCreation(medioId: 5, numeroAFIP: 3, nombre: "PdV Sur", descripcion: null);
Assert.Equal(0, pdv.Id);
Assert.Equal(5, pdv.MedioId);
Assert.Equal(3, pdv.NumeroAFIP);
Assert.Equal("PdV Sur", pdv.Nombre);
Assert.Null(pdv.Descripcion);
Assert.True(pdv.Activo);
}
[Fact]
public void ForCreation_WithDescripcion_SetsDescripcion()
{
var pdv = PuntoDeVenta.ForCreation(5, 1, "PdV Norte", "Descripcion larga");
Assert.Equal("Descripcion larga", pdv.Descripcion);
}
// ── WithUpdatedProfile ────────────────────────────────────────────────────
[Fact]
public void WithUpdatedProfile_ReturnsNewInstanceWithUpdatedFields()
{
var original = MakePdv(id: 10, nombre: "Original");
var updated = original.WithUpdatedProfile(nombre: "Actualizado", numeroAFIP: 7, descripcion: "Desc");
Assert.NotSame(original, updated);
Assert.Equal("Actualizado", updated.Nombre);
Assert.Equal(7, updated.NumeroAFIP);
Assert.Equal("Desc", updated.Descripcion);
}
[Fact]
public void WithUpdatedProfile_ImmutableFields_Preserved()
{
var original = MakePdv(id: 10, medioId: 5);
var updated = original.WithUpdatedProfile("Nuevo", 2, null);
Assert.Equal(10, updated.Id);
Assert.Equal(5, updated.MedioId);
Assert.Equal(original.Activo, updated.Activo);
Assert.Equal(original.FechaCreacion, updated.FechaCreacion);
}
[Fact]
public void WithUpdatedProfile_SetsFechaModificacion()
{
var original = MakePdv();
var updated = original.WithUpdatedProfile("Nuevo", 2, null);
Assert.NotNull(updated.FechaModificacion);
}
// ── WithActivo ────────────────────────────────────────────────────────────
[Fact]
public void WithActivo_False_ReturnsDomainObjectWithActivoFalse()
{
var pdv = MakePdv(activo: true);
var deactivated = pdv.WithActivo(false);
Assert.False(deactivated.Activo);
Assert.NotSame(pdv, deactivated);
}
[Fact]
public void WithActivo_True_ReturnsDomainObjectWithActivoTrue()
{
var pdv = MakePdv(activo: false);
var reactivated = pdv.WithActivo(true);
Assert.True(reactivated.Activo);
}
[Fact]
public void WithActivo_ImmutableFields_Preserved()
{
var pdv = MakePdv(id: 99, medioId: 3);
var toggled = pdv.WithActivo(false);
Assert.Equal(99, toggled.Id);
Assert.Equal(3, toggled.MedioId);
Assert.Equal(pdv.NumeroAFIP, toggled.NumeroAFIP);
Assert.Equal(pdv.Nombre, toggled.Nombre);
}
// ── Constructor sets all properties ───────────────────────────────────────
[Fact]
public void Constructor_SetsAllProperties()
{
var now = DateTime.UtcNow;
var pdv = new PuntoDeVenta(
id: 7,
medioId: 3,
numeroAFIP: 4,
nombre: "PdV Test",
descripcion: "Desc Test",
activo: true,
fechaCreacion: now,
fechaModificacion: null);
Assert.Equal(7, pdv.Id);
Assert.Equal(3, pdv.MedioId);
Assert.Equal(4, pdv.NumeroAFIP);
Assert.Equal("PdV Test", pdv.Nombre);
Assert.Equal("Desc Test", pdv.Descripcion);
Assert.True(pdv.Activo);
Assert.Equal(now, pdv.FechaCreacion);
Assert.Null(pdv.FechaModificacion);
}
}

View File

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

View File

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

View File

@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos() public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
{ {
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) // 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"); var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(22, permisos.Count); Assert.Equal(23, permisos.Count);
} }
[Fact] [Fact]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
await EnsureV011SchemaAsync(); await EnsureV011SchemaAsync();
// V013 (ADM-008): ensure dbo.PuntoDeVenta + temporal + permiso. Drops idempotentes
// de SecuenciaComprobante + SP usp_ReservarNumeroComprobante (cirugía post-smoke:
// IMAC asigna numeros AFIP, no nosotros — ver memoria architecture/facturacion-imac-numeracion).
await EnsureV013SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -56,6 +61,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "RolPermiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"),
new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"), new Respawn.Graph.Table("dbo", "Seccion_History"),
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
] ]
}); });
@@ -165,7 +171,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'), ('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'), ('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 -- 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 AFIP','administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo) ) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN WHEN NOT MATCHED BY TARGET THEN
@@ -207,6 +215,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:permisos:ver'), ('admin', 'administracion:permisos:ver'),
-- V011 (ADM-001) -- V011 (ADM-001)
('admin', 'administracion:secciones:gestionar'), ('admin', 'administracion:secciones:gestionar'),
-- V013 (ADM-008)
('admin', 'administracion:puntos_de_venta:gestionar'),
('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'), ('cajero', 'ventas:contado:cobrar'),
@@ -373,6 +383,104 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
} }
/// <summary>
/// ADM-008 (V013): applies dbo.PuntoDeVenta schema + temporal table.
/// NOTE: SecuenciaComprobante y SP usp_ReservarNumeroComprobante fueron eliminados
/// post-smoke (Batch 9) — IMAC/Infogestión asigna los números AFIP externamente.
/// Este método también hace DROP idempotente de esos artefactos en caso de que
/// SIGCM2_Test los tuviera de una versión previa de la migración V013.
/// Permiso y asignación se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
/// </summary>
private async Task EnsureV013SchemaAsync()
{
// ── Drops idempotentes de artefactos eliminados (cirugía post-smoke) ──
// Si SIGCM2_Test tiene SecuenciaComprobante o el SP de una versión previa, se limpian.
const string dropSp = """
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
""";
const string disableSecuenciaVersioning = """
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
""";
const string dropSecuenciaHistory = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
DROP TABLE dbo.SecuenciaComprobante_History;
""";
const string dropSecuencia = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
DROP TABLE dbo.SecuenciaComprobante;
""";
// ── PuntoDeVenta: crear si no existe ──────────────────────────────────
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 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
""";
// Drops primero (limpieza de versión previa)
await _connection.ExecuteAsync(dropSp);
await _connection.ExecuteAsync(disableSecuenciaVersioning);
await _connection.ExecuteAsync(dropSecuenciaHistory);
await _connection.ExecuteAsync(dropSecuencia);
// Luego crear PuntoDeVenta + Temporal Table
await _connection.ExecuteAsync(createPdv);
await _connection.ExecuteAsync(createPdvIndex);
await _connection.ExecuteAsync(addPdvPeriod);
await _connection.ExecuteAsync(setPdvVersioning);
}
/// <summary> /// <summary>
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
/// </summary> /// </summary>