ADM-008: Puntos de Venta (CRUD fundacional) #19
1
.vite/vitest/results.json
Normal file
1
.vite/vitest/results.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"2.1.9","results":[[":src/web/src/tests/stores/authStore.test.ts",{"duration":19.427999999999997,"failed":true}],[":src/web/src/tests/features/auth/ProtectedRoute.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/api/axiosClient.test.ts",{"duration":259.31550000000016,"failed":true}],[":src/web/src/tests/features/users/UserForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UsersListPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/LoginPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/useLogin.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UserEditPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/ResetPasswordModal.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/authApi.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolesList.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useCreateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/CanPerform.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/usePermission.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useUsersList.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/listUsers.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/updateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/getUser.test.ts",{"duration":0,"failed":true}]]}
|
||||
81
database/migrations/V013_ROLLBACK.sql
Normal file
81
database/migrations/V013_ROLLBACK.sql
Normal 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
|
||||
179
database/migrations/V013__create_puntos_de_venta.sql
Normal file
179
database/migrations/V013__create_puntos_de_venta.sql
Normal 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
|
||||
175
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
175
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal 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);
|
||||
@@ -231,6 +231,31 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-008: PuntoDeVenta exceptions
|
||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "punto_de_venta_not_found",
|
||||
message = puntoDeVentaNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "numero_afip_duplicado",
|
||||
message = numeroAFIPDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// UDT-009: permiso override validation errors
|
||||
case InvalidPermisoCodesException ipce:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal file
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>Query parameters for listing puntos de venta with optional filters and paging.</summary>
|
||||
public sealed record PuntosDeVentaQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
int? MedioId,
|
||||
bool? Activo
|
||||
);
|
||||
@@ -21,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Application.Roles.Get;
|
||||
using SIGCM2.Application.Roles.List;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
using SIGCM2.Application.PuntosDeVenta.Create;
|
||||
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
using SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
using SIGCM2.Application.PuntosDeVenta.List;
|
||||
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||
using SIGCM2.Application.PuntosDeVenta.Update;
|
||||
using SIGCM2.Application.Secciones.Create;
|
||||
using SIGCM2.Application.Secciones.Deactivate;
|
||||
using SIGCM2.Application.Secciones.GetById;
|
||||
@@ -90,6 +96,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
||||
|
||||
// Puntos de Venta (ADM-008)
|
||||
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Create;
|
||||
|
||||
public sealed record CreatePuntoDeVentaCommand(
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
string? Descripcion);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Create;
|
||||
|
||||
public sealed class CreatePuntoDeVentaCommandHandler : ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public CreatePuntoDeVentaCommandHandler(
|
||||
IPuntoDeVentaRepository repo,
|
||||
IMedioRepository medioRepo,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_medioRepo = medioRepo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<PuntoDeVentaCreatedDto> Handle(CreatePuntoDeVentaCommand command)
|
||||
{
|
||||
// Validate medio exists and is active (REQ-PDV-001, -002)
|
||||
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
|
||||
?? throw new MedioNotFoundException(command.MedioId);
|
||||
|
||||
if (!medio.Activo)
|
||||
throw new MedioInactivoException(medio.Id);
|
||||
|
||||
// Check uniqueness NumeroAFIP within Medio (REQ-PDV-003)
|
||||
var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(command.MedioId, command.NumeroAFIP, excludeId: null);
|
||||
if (exists)
|
||||
throw new NumeroAFIPDuplicadoException(command.MedioId, command.NumeroAFIP);
|
||||
|
||||
var pdv = PuntoDeVenta.ForCreation(command.MedioId, command.NumeroAFIP, command.Nombre, command.Descripcion);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var newId = await _repo.AddAsync(pdv);
|
||||
|
||||
// fail-closed: if LogAsync throws, tx rolls back (REQ-PDV-010)
|
||||
await _audit.LogAsync(
|
||||
action: "punto_de_venta.create",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
pdv.MedioId,
|
||||
pdv.NumeroAFIP,
|
||||
pdv.Nombre,
|
||||
pdv.Descripcion,
|
||||
},
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new PuntoDeVentaCreatedDto(
|
||||
Id: newId,
|
||||
MedioId: pdv.MedioId,
|
||||
NumeroAFIP: pdv.NumeroAFIP,
|
||||
Nombre: pdv.Nombre,
|
||||
Descripcion: pdv.Descripcion,
|
||||
Activo: pdv.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Create;
|
||||
|
||||
public sealed class CreatePuntoDeVentaCommandValidator : AbstractValidator<CreatePuntoDeVentaCommand>
|
||||
{
|
||||
private const int NombreMaxLength = 100;
|
||||
private const int DescripcionMaxLength = 255;
|
||||
private const short NumeroAFIPMin = 1;
|
||||
private const short NumeroAFIPMax = 9999;
|
||||
|
||||
public CreatePuntoDeVentaCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.MedioId)
|
||||
.GreaterThan(0).WithMessage("El medioId debe ser mayor a 0.");
|
||||
|
||||
RuleFor(x => x.NumeroAFIP)
|
||||
.InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax)
|
||||
.WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||
|
||||
RuleFor(x => x.Descripcion)
|
||||
.MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
|
||||
.When(x => x.Descripcion is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Create;
|
||||
|
||||
public sealed record PuntoDeVentaCreatedDto(
|
||||
int Id,
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
|
||||
public sealed record DeactivatePuntoDeVentaCommand(int Id);
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
|
||||
public sealed class DeactivatePuntoDeVentaCommandHandler : ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public DeactivatePuntoDeVentaCommandHandler(
|
||||
IPuntoDeVentaRepository repo,
|
||||
IMedioRepository medioRepo,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_medioRepo = medioRepo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<PuntoDeVentaStatusDto> Handle(DeactivatePuntoDeVentaCommand command)
|
||||
{
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new PuntoDeVentaNotFoundException(command.Id);
|
||||
|
||||
// Idempotent: already inactive → return as-is without writing an audit event
|
||||
if (!target.Activo)
|
||||
return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo);
|
||||
|
||||
var updated = target.WithActivo(false);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "punto_de_venta.deactivate",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: command.Id.ToString());
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
|
||||
public sealed record PuntoDeVentaStatusDto(int Id, short NumeroAFIP, bool Activo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
|
||||
public sealed record GetPuntoDeVentaByIdQuery(int Id);
|
||||
@@ -0,0 +1,31 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
|
||||
public sealed class GetPuntoDeVentaByIdQueryHandler : ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
|
||||
public GetPuntoDeVentaByIdQueryHandler(IPuntoDeVentaRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PuntoDeVentaDetailDto> Handle(GetPuntoDeVentaByIdQuery query)
|
||||
{
|
||||
var pdv = await _repo.GetByIdAsync(query.Id)
|
||||
?? throw new PuntoDeVentaNotFoundException(query.Id);
|
||||
|
||||
return new PuntoDeVentaDetailDto(
|
||||
Id: pdv.Id,
|
||||
MedioId: pdv.MedioId,
|
||||
NumeroAFIP: pdv.NumeroAFIP,
|
||||
Nombre: pdv.Nombre,
|
||||
Descripcion: pdv.Descripcion,
|
||||
Activo: pdv.Activo,
|
||||
FechaCreacion: pdv.FechaCreacion,
|
||||
FechaModificacion: pdv.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
|
||||
public sealed record PuntoDeVentaDetailDto(
|
||||
int Id,
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.List;
|
||||
|
||||
public sealed record ListPuntosDeVentaQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
int? MedioId,
|
||||
bool? Activo);
|
||||
@@ -0,0 +1,30 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.List;
|
||||
|
||||
public sealed class ListPuntosDeVentaQueryHandler : ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
|
||||
public ListPuntosDeVentaQueryHandler(IPuntoDeVentaRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<PuntoDeVentaListItemDto>> Handle(ListPuntosDeVentaQuery query)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var repoQuery = new PuntosDeVentaQuery(page, pageSize, query.MedioId, query.Activo);
|
||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||
|
||||
var items = paged.Items
|
||||
.Select(p => new PuntoDeVentaListItemDto(p.Id, p.MedioId, p.NumeroAFIP, p.Nombre, p.Activo))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<PuntoDeVentaListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.List;
|
||||
|
||||
public sealed record PuntoDeVentaListItemDto(
|
||||
int Id,
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
bool Activo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||
|
||||
public sealed record ReactivatePuntoDeVentaCommand(int Id);
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||
|
||||
public sealed class ReactivatePuntoDeVentaCommandHandler : ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ReactivatePuntoDeVentaCommandHandler(
|
||||
IPuntoDeVentaRepository repo,
|
||||
IMedioRepository medioRepo,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_medioRepo = medioRepo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<PuntoDeVentaStatusDto> Handle(ReactivatePuntoDeVentaCommand command)
|
||||
{
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new PuntoDeVentaNotFoundException(command.Id);
|
||||
|
||||
var medio = await _medioRepo.GetByIdAsync(target.MedioId)
|
||||
?? throw new MedioNotFoundException(target.MedioId);
|
||||
|
||||
if (!medio.Activo)
|
||||
throw new MedioInactivoException(medio.Id);
|
||||
|
||||
// Idempotent: already active → return as-is without writing an audit event
|
||||
if (target.Activo)
|
||||
return new PuntoDeVentaStatusDto(target.Id, target.NumeroAFIP, target.Activo);
|
||||
|
||||
var updated = target.WithActivo(true);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "punto_de_venta.reactivate",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: command.Id.ToString());
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new PuntoDeVentaStatusDto(updated.Id, updated.NumeroAFIP, updated.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Update;
|
||||
|
||||
public sealed record PuntoDeVentaUpdatedDto(
|
||||
int Id,
|
||||
int MedioId,
|
||||
short NumeroAFIP,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Update;
|
||||
|
||||
public sealed record UpdatePuntoDeVentaCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
short NumeroAFIP,
|
||||
string? Descripcion);
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Update;
|
||||
|
||||
public sealed class UpdatePuntoDeVentaCommandHandler : ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UpdatePuntoDeVentaCommandHandler(
|
||||
IPuntoDeVentaRepository repo,
|
||||
IMedioRepository medioRepo,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repo = repo;
|
||||
_medioRepo = medioRepo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<PuntoDeVentaUpdatedDto> Handle(UpdatePuntoDeVentaCommand command)
|
||||
{
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new PuntoDeVentaNotFoundException(command.Id);
|
||||
|
||||
var medio = await _medioRepo.GetByIdAsync(target.MedioId)
|
||||
?? throw new MedioNotFoundException(target.MedioId);
|
||||
|
||||
if (!medio.Activo)
|
||||
throw new MedioInactivoException(medio.Id);
|
||||
|
||||
// Re-validate uniqueness excluding current entity (REQ-PDV-004)
|
||||
var exists = await _repo.ExistsByNumeroAFIPInMedioAsync(target.MedioId, command.NumeroAFIP, excludeId: command.Id);
|
||||
if (exists)
|
||||
throw new NumeroAFIPDuplicadoException(target.MedioId, command.NumeroAFIP);
|
||||
|
||||
var updated = target.WithUpdatedProfile(command.Nombre, command.NumeroAFIP, command.Descripcion);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "punto_de_venta.update",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { target.Nombre, target.NumeroAFIP, target.Descripcion },
|
||||
after = new { updated.Nombre, updated.NumeroAFIP, updated.Descripcion },
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new PuntoDeVentaUpdatedDto(
|
||||
Id: updated.Id,
|
||||
MedioId: updated.MedioId,
|
||||
NumeroAFIP: updated.NumeroAFIP,
|
||||
Nombre: updated.Nombre,
|
||||
Descripcion: updated.Descripcion,
|
||||
Activo: updated.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.PuntosDeVenta.Update;
|
||||
|
||||
public sealed class UpdatePuntoDeVentaCommandValidator : AbstractValidator<UpdatePuntoDeVentaCommand>
|
||||
{
|
||||
private const int NombreMaxLength = 100;
|
||||
private const int DescripcionMaxLength = 255;
|
||||
private const short NumeroAFIPMin = 1;
|
||||
private const short NumeroAFIPMax = 9999;
|
||||
|
||||
public UpdatePuntoDeVentaCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
|
||||
|
||||
RuleFor(x => x.NumeroAFIP)
|
||||
.InclusiveBetween(NumeroAFIPMin, NumeroAFIPMax)
|
||||
.WithMessage($"El número AFIP debe estar entre {NumeroAFIPMin} y {NumeroAFIPMax}.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||
|
||||
RuleFor(x => x.Descripcion)
|
||||
.MaximumLength(DescripcionMaxLength).WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
|
||||
.When(x => x.Descripcion is not null);
|
||||
}
|
||||
}
|
||||
77
src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs
Normal file
77
src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
||||
services.AddScoped<IMedioRepository, MedioRepository>();
|
||||
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
||||
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PanelLeftOpen,
|
||||
Newspaper,
|
||||
Columns3,
|
||||
Store,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -61,6 +62,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Columns3,
|
||||
requiredPermission: 'administracion:secciones:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Puntos de Venta',
|
||||
href: '/admin/puntos-de-venta',
|
||||
icon: Store,
|
||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type {
|
||||
CreatePuntoDeVentaRequest,
|
||||
PuntoDeVentaCreated,
|
||||
PuntoDeVentaDetail,
|
||||
PuntoDeVentaListItem,
|
||||
PuntosDeVentaQuery,
|
||||
UpdatePuntoDeVentaRequest,
|
||||
PagedResult,
|
||||
} from '../types'
|
||||
|
||||
export async function listPuntosDeVenta(
|
||||
query: PuntosDeVentaQuery,
|
||||
): Promise<PagedResult<PuntoDeVentaListItem>> {
|
||||
const params = new URLSearchParams()
|
||||
if (query.page !== undefined) params.set('page', String(query.page))
|
||||
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||
if (query.medioId !== undefined) params.set('medioId', String(query.medioId))
|
||||
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||
|
||||
const response = await axiosClient.get<PagedResult<PuntoDeVentaListItem>>(
|
||||
'/api/v1/admin/puntos-de-venta',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getPuntoDeVenta(id: number): Promise<PuntoDeVentaDetail> {
|
||||
const response = await axiosClient.get<PuntoDeVentaDetail>(
|
||||
`/api/v1/admin/puntos-de-venta/${id}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createPuntoDeVenta(
|
||||
payload: CreatePuntoDeVentaRequest,
|
||||
): Promise<PuntoDeVentaCreated> {
|
||||
const response = await axiosClient.post<PuntoDeVentaCreated>(
|
||||
'/api/v1/admin/puntos-de-venta',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updatePuntoDeVenta(
|
||||
id: number,
|
||||
payload: UpdatePuntoDeVentaRequest,
|
||||
): Promise<PuntoDeVentaDetail> {
|
||||
const response = await axiosClient.put<PuntoDeVentaDetail>(
|
||||
`/api/v1/admin/puntos-de-venta/${id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deactivatePuntoDeVenta(id: number): Promise<void> {
|
||||
await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/deactivate`)
|
||||
}
|
||||
|
||||
export async function reactivatePuntoDeVenta(id: number): Promise<void> {
|
||||
await axiosClient.post(`/api/v1/admin/puntos-de-venta/${id}/reactivate`)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDeactivatePuntoDeVenta, useReactivatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
|
||||
|
||||
interface DeactivatePuntoDeVentaModalProps {
|
||||
puntoDeVentaId: number
|
||||
puntoDeVentaNombre: string
|
||||
activo: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DeactivatePuntoDeVentaModal({
|
||||
puntoDeVentaId,
|
||||
puntoDeVentaNombre,
|
||||
activo,
|
||||
disabled = false,
|
||||
}: DeactivatePuntoDeVentaModalProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { mutate: deactivate, isPending: deactivating } = useDeactivatePuntoDeVenta()
|
||||
const { mutate: reactivate, isPending: reactivating } = useReactivatePuntoDeVenta()
|
||||
|
||||
const isPending = deactivating || reactivating
|
||||
|
||||
function handleConfirm() {
|
||||
if (activo) {
|
||||
deactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) })
|
||||
} else {
|
||||
reactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
{activo ? 'Desactivar' : 'Reactivar'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{activo ? 'Desactivar punto de venta' : 'Reactivar punto de venta'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{activo
|
||||
? `¿Confirmás que querés desactivar el punto de venta "${puntoDeVentaNombre}"?`
|
||||
: `¿Confirmás que querés reactivar el punto de venta "${puntoDeVentaNombre}"?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
interface MedioInactivoBannerProps {
|
||||
medioNombre: string
|
||||
}
|
||||
|
||||
export function MedioInactivoBanner({ medioNombre }: MedioInactivoBannerProps) {
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Medio desactivado</AlertTitle>
|
||||
<AlertDescription>
|
||||
El medio "{medioNombre}" está desactivado. Las operaciones de edición, activación y
|
||||
desactivación de sus puntos de venta están bloqueadas hasta que se reactive el medio.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
interface PdvInactivoBannerProps {
|
||||
puntoDeVentaNombre: string
|
||||
}
|
||||
|
||||
export function PdvInactivoBanner({ puntoDeVentaNombre }: PdvInactivoBannerProps) {
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Punto de venta desactivado</AlertTitle>
|
||||
<AlertDescription>
|
||||
El punto de venta "{puntoDeVentaNombre}" está desactivado. Reactivalo para habilitar
|
||||
nuevamente sus operaciones.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||
import type { PuntoDeVentaDetail } from '../types'
|
||||
|
||||
const puntoDeVentaFormSchema = z.object({
|
||||
medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'),
|
||||
numeroAFIP: z.coerce
|
||||
.number({ invalid_type_error: 'El número AFIP debe ser un número' })
|
||||
.int('Debe ser un número entero')
|
||||
.min(1, 'El número AFIP debe ser mayor a 0'),
|
||||
nombre: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(100, 'Máximo 100 caracteres'),
|
||||
})
|
||||
|
||||
export type PuntoDeVentaFormValues = z.infer<typeof puntoDeVentaFormSchema>
|
||||
|
||||
interface PuntoDeVentaFormProps {
|
||||
initialData?: PuntoDeVentaDetail
|
||||
isPending: boolean
|
||||
error: unknown
|
||||
onSubmit: (values: PuntoDeVentaFormValues) => void
|
||||
}
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
if (data.error === 'numero_afip_duplicado') {
|
||||
return data.message ?? 'Ya existe un punto de venta con ese número AFIP en el medio'
|
||||
}
|
||||
if (data.error === 'medio_inactivo') {
|
||||
return data.message ?? 'El medio está inactivo. Reactivalo antes de operar.'
|
||||
}
|
||||
return data.message ?? data.error ?? 'Error al guardar el punto de venta'
|
||||
}
|
||||
return 'Error al guardar el punto de venta'
|
||||
}
|
||||
|
||||
export function PuntoDeVentaForm({ initialData, isPending, error, onSubmit }: PuntoDeVentaFormProps) {
|
||||
const isEdit = !!initialData
|
||||
|
||||
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 })
|
||||
const medios = mediosData?.items ?? []
|
||||
|
||||
const form = useForm<PuntoDeVentaFormValues>({
|
||||
resolver: zodResolver(puntoDeVentaFormSchema),
|
||||
defaultValues: {
|
||||
medioId: initialData?.medioId ?? ('' as unknown as number),
|
||||
numeroAFIP: initialData?.numeroAFIP ?? ('' as unknown as number),
|
||||
nombre: initialData?.nombre ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
medioId: initialData.medioId,
|
||||
numeroAFIP: initialData.numeroAFIP,
|
||||
nombre: initialData.nombre,
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const backendError = resolveBackendError(error)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="medioId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Medio</FormLabel>
|
||||
<Select
|
||||
value={field.value ? String(field.value) : ''}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
disabled={isPending || isEdit}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full" aria-label="Medio">
|
||||
<SelectValue placeholder="Seleccioná un medio" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{medios.map((m) => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>
|
||||
{m.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="numeroAFIP"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Número AFIP</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="Ej: 1"
|
||||
aria-label="Número AFIP"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre del punto de venta"
|
||||
aria-label="Nombre"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear punto de venta'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||
|
||||
interface PuntosDeVentaFiltersProps {
|
||||
onMedioIdChange: (medioId: number | undefined) => void
|
||||
onActivoChange: (activo: boolean | undefined) => void
|
||||
}
|
||||
|
||||
export function PuntosDeVentaFilters({
|
||||
onMedioIdChange,
|
||||
onActivoChange,
|
||||
}: PuntosDeVentaFiltersProps) {
|
||||
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true })
|
||||
const medios = mediosData?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||
<Select
|
||||
defaultValue="__all__"
|
||||
onValueChange={(v) => onMedioIdChange(v === '__all__' ? undefined : Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-52" aria-label="Medio">
|
||||
<SelectValue placeholder="Todos los medios" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Todos los medios</SelectItem>
|
||||
{medios.map((m) => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>
|
||||
{m.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
defaultValue="__all__"
|
||||
onValueChange={(v) => {
|
||||
if (v === '__all__') onActivoChange(undefined)
|
||||
else onActivoChange(v === 'true')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-32" aria-label="Estado">
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Todos</SelectItem>
|
||||
<SelectItem value="true">Activos</SelectItem>
|
||||
<SelectItem value="false">Inactivos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTable } from '@/components/ui/data-table'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import type { PuntoDeVentaListItem } from '../types'
|
||||
import { DeactivatePuntoDeVentaModal } from './DeactivatePuntoDeVentaModal'
|
||||
|
||||
interface PuntosDeVentaTableProps {
|
||||
rows: PuntoDeVentaListItem[]
|
||||
medioInactivo?: boolean
|
||||
}
|
||||
|
||||
export function PuntosDeVentaTable({ rows, medioInactivo = false }: PuntosDeVentaTableProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const columns = useMemo<ColumnDef<PuntoDeVentaListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'numeroAFIP',
|
||||
header: 'N° AFIP',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.numeroAFIP}</span>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'nombre',
|
||||
header: 'Nombre',
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'medioId',
|
||||
header: 'Medio ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.medioId}</span>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'activo',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) =>
|
||||
row.original.activo ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Activo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Inactivo
|
||||
</Badge>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
id: 'acciones',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<CanPerform permission="administracion:puntos_de_venta:gestionar">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={medioInactivo}
|
||||
onClick={() => navigate(`/admin/puntos-de-venta/${row.original.id}/edit`)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<DeactivatePuntoDeVentaModal
|
||||
puntoDeVentaId={row.original.id}
|
||||
puntoDeVentaNombre={row.original.nombre}
|
||||
activo={row.original.activo}
|
||||
disabled={medioInactivo}
|
||||
/>
|
||||
</CanPerform>
|
||||
</div>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
],
|
||||
[navigate, medioInactivo],
|
||||
)
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => navigate(`/admin/puntos-de-venta/${row.id}`)}
|
||||
getRowId={(row) => String(row.id)}
|
||||
emptyMessage="Sin resultados — no se encontraron puntos de venta con los filtros seleccionados."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
listPuntosDeVenta,
|
||||
getPuntoDeVenta,
|
||||
createPuntoDeVenta,
|
||||
updatePuntoDeVenta,
|
||||
deactivatePuntoDeVenta,
|
||||
reactivatePuntoDeVenta,
|
||||
} from '../api/puntos-de-venta.api'
|
||||
import type { CreatePuntoDeVentaRequest, PuntosDeVentaQuery, UpdatePuntoDeVentaRequest } from '../types'
|
||||
|
||||
export const puntosDeVentaListQueryKey = (query: PuntosDeVentaQuery) =>
|
||||
['puntos-de-venta', 'list', query] as const
|
||||
|
||||
export const puntoDeVentaDetailQueryKey = (id: number) =>
|
||||
['puntos-de-venta', 'detail', id] as const
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usePuntosDeVentaList(query: PuntosDeVentaQuery) {
|
||||
return useQuery({
|
||||
queryKey: puntosDeVentaListQueryKey(query),
|
||||
queryFn: () => listPuntosDeVenta(query),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usePuntoDeVenta(id: number) {
|
||||
return useQuery({
|
||||
queryKey: puntoDeVentaDetailQueryKey(id),
|
||||
queryFn: () => getPuntoDeVenta(id),
|
||||
enabled: !!id,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreatePuntoDeVenta() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreatePuntoDeVentaRequest) => createPuntoDeVenta(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUpdatePuntoDeVenta(id: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdatePuntoDeVentaRequest) => updatePuntoDeVenta(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Deactivate / Reactivate ─────────────────────────────────────────────────
|
||||
|
||||
export function useDeactivatePuntoDeVenta() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deactivatePuntoDeVenta(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReactivatePuntoDeVenta() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => reactivatePuntoDeVenta(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['puntos-de-venta'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm'
|
||||
import { useCreatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
|
||||
import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm'
|
||||
|
||||
export function CreatePuntoDeVentaPage() {
|
||||
const navigate = useNavigate()
|
||||
const { mutate, isPending, error } = useCreatePuntoDeVenta()
|
||||
|
||||
function handleSubmit(values: PuntoDeVentaFormValues) {
|
||||
mutate(
|
||||
{
|
||||
medioId: values.medioId,
|
||||
numeroAFIP: values.numeroAFIP,
|
||||
nombre: values.nombre,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Punto de venta creado correctamente')
|
||||
void navigate('/admin/puntos-de-venta')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Crear Punto de Venta</CardTitle>
|
||||
<CardDescription>
|
||||
Completá los datos para registrar un nuevo punto de venta AFIP.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PuntoDeVentaForm isPending={isPending} error={error} onSubmit={handleSubmit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm'
|
||||
import { usePuntoDeVenta, useUpdatePuntoDeVenta } from '../hooks/usePuntosDeVenta'
|
||||
import { useMedio } from '../../medios/hooks/useMedio'
|
||||
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
|
||||
import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm'
|
||||
|
||||
export function EditPuntoDeVentaPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const puntoDeVentaId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId)
|
||||
const { mutate, isPending, error } = useUpdatePuntoDeVenta(puntoDeVentaId)
|
||||
const { data: medio } = useMedio(pdv?.medioId ?? 0)
|
||||
const medioInactivo = medio?.activo === false
|
||||
|
||||
function handleSubmit(values: PuntoDeVentaFormValues) {
|
||||
mutate(
|
||||
{
|
||||
nombre: values.nombre,
|
||||
numeroAFIP: values.numeroAFIP,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Punto de venta actualizado correctamente')
|
||||
void navigate(`/admin/puntos-de-venta/${puntoDeVentaId}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="text-muted-foreground">Cargando...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pdv) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Punto de venta no encontrado.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">Editar Punto de Venta</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/admin/puntos-de-venta')}
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Editá los datos del punto de venta <strong>{pdv.nombre}</strong>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{medioInactivo && medio && (
|
||||
<MedioInactivoBanner medioNombre={medio.nombre} />
|
||||
)}
|
||||
<PuntoDeVentaForm
|
||||
initialData={pdv}
|
||||
isPending={isPending || medioInactivo}
|
||||
error={error}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
55
src/web/src/features/puntos-de-venta/types.ts
Normal file
55
src/web/src/features/puntos-de-venta/types.ts
Normal 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
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage'
|
||||
import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage'
|
||||
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
|
||||
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage'
|
||||
import { PuntosDeVentaListPage } from './features/puntos-de-venta/pages/PuntosDeVentaListPage'
|
||||
import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage'
|
||||
import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage'
|
||||
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -240,6 +244,40 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Puntos de Venta routes */}
|
||||
<Route
|
||||
path="/admin/puntos-de-venta"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||
<PuntosDeVentaListPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/puntos-de-venta/nuevo"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||
<CreatePuntoDeVentaPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/puntos-de-venta/:id"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||
<PuntoDeVentaDetailPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/puntos-de-venta/:id/edit"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||
<EditPuntoDeVentaPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
30
src/web/src/tests/features/puntos-de-venta/Banners.test.tsx
Normal file
30
src/web/src/tests/features/puntos-de-venta/Banners.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MedioInactivoBanner } from '../../../features/puntos-de-venta/components/MedioInactivoBanner'
|
||||
import { PdvInactivoBanner } from '../../../features/puntos-de-venta/components/PdvInactivoBanner'
|
||||
|
||||
describe('MedioInactivoBanner', () => {
|
||||
it('renders with medio nombre', () => {
|
||||
render(<MedioInactivoBanner medioNombre="Diario El Día" />)
|
||||
expect(screen.getByText(/medio desactivado/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/diario el día/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders blocked operations message', () => {
|
||||
render(<MedioInactivoBanner medioNombre="Radio AM" />)
|
||||
expect(screen.getByText(/puntos de venta/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PdvInactivoBanner', () => {
|
||||
it('renders with pdv nombre', () => {
|
||||
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Central" />)
|
||||
expect(screen.getByText(/punto de venta desactivado/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/pdv central/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders reactivate hint', () => {
|
||||
render(<PdvInactivoBanner puntoDeVentaNombre="PdV Sur" />)
|
||||
expect(screen.getByText(/reactivalo/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { DeactivatePuntoDeVentaModal } from '../../../features/puntos-de-venta/components/DeactivatePuntoDeVentaModal'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderModal(activo = true) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<DeactivatePuntoDeVentaModal
|
||||
puntoDeVentaId={1}
|
||||
puntoDeVentaNombre="PdV Central"
|
||||
activo={activo}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DeactivatePuntoDeVentaModal', () => {
|
||||
it('shows "Desactivar" trigger when pdv is active', () => {
|
||||
renderModal(true)
|
||||
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Reactivar" trigger when pdv is inactive', () => {
|
||||
renderModal(false)
|
||||
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dialog and shows confirmation text', async () => {
|
||||
renderModal(true)
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/desactivar punto de venta/i)).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByText(/pdv central/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls deactivate endpoint on confirm', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/puntos-de-venta/1/deactivate`, () => {
|
||||
called = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
|
||||
renderModal(true)
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||
await waitFor(() => screen.getByRole('alertdialog'))
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar$/i }))
|
||||
|
||||
await waitFor(() => expect(called).toBe(true))
|
||||
})
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<DeactivatePuntoDeVentaModal
|
||||
puntoDeVentaId={1}
|
||||
puntoDeVentaNombre="PdV Central"
|
||||
activo={true}
|
||||
disabled={true}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /desactivar/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { PuntoDeVentaForm } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm'
|
||||
import type { PuntoDeVentaFormValues } from '../../../features/puntos-de-venta/components/PuntoDeVentaForm'
|
||||
import type { PuntoDeVentaDetail } from '../../../features/puntos-de-venta/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockMedios = [
|
||||
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
|
||||
{ id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
|
||||
]
|
||||
|
||||
const samplePdv: PuntoDeVentaDetail = {
|
||||
id: 10,
|
||||
medioId: 1,
|
||||
numeroAFIP: 1,
|
||||
nombre: 'PdV Central',
|
||||
activo: true,
|
||||
fechaCreacion: '2026-01-01T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderForm(
|
||||
opts: { initialData?: PuntoDeVentaDetail; onSubmit?: (v: PuntoDeVentaFormValues) => void } = {},
|
||||
) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const onSubmit = opts.onSubmit ?? vi.fn()
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }),
|
||||
),
|
||||
)
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<PuntoDeVentaForm
|
||||
initialData={opts.initialData}
|
||||
isPending={false}
|
||||
error={null}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
return { onSubmit }
|
||||
}
|
||||
|
||||
describe('PuntoDeVentaForm — create mode', () => {
|
||||
it('shows validation error when nombre is empty', async () => {
|
||||
renderForm()
|
||||
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows validation error when numeroAFIP is 0 or negative', async () => {
|
||||
renderForm()
|
||||
const numeroInput = screen.getByLabelText(/número afip/i)
|
||||
await userEvent.clear(numeroInput)
|
||||
await userEvent.type(numeroInput, '0')
|
||||
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/mayor a 0/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onSubmit with correct values on valid form', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
renderForm({ onSubmit })
|
||||
|
||||
// Select medio
|
||||
const medioTrigger = screen.getByRole('combobox', { name: /medio/i })
|
||||
await userEvent.click(medioTrigger)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(),
|
||||
)
|
||||
await userEvent.click(screen.getByRole('option', { name: 'Diario El Día' }))
|
||||
|
||||
// Fill numeroAFIP
|
||||
const numeroInput = screen.getByLabelText(/número afip/i)
|
||||
await userEvent.clear(numeroInput)
|
||||
await userEvent.type(numeroInput, '3')
|
||||
|
||||
// Fill nombre
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'PdV Norte')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /crear punto de venta/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const firstArg = onSubmit.mock.calls[0][0]
|
||||
expect(firstArg).toMatchObject({
|
||||
medioId: 1,
|
||||
numeroAFIP: 3,
|
||||
nombre: 'PdV Norte',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PuntoDeVentaForm — edit mode', () => {
|
||||
it('medioId and numeroAFIP are disabled in edit mode', async () => {
|
||||
renderForm({ initialData: samplePdv })
|
||||
const medioTrigger = screen.getByRole('combobox', { name: /medio/i })
|
||||
expect(medioTrigger).toBeDisabled()
|
||||
|
||||
const numeroInput = screen.getByLabelText(/número afip/i) as HTMLInputElement
|
||||
expect(numeroInput.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills form with initialData values', async () => {
|
||||
renderForm({ initialData: samplePdv })
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('PdV Central'),
|
||||
)
|
||||
expect((screen.getByLabelText(/número afip/i) as HTMLInputElement).value).toBe('1')
|
||||
})
|
||||
|
||||
it('shows "Guardar cambios" button in edit mode', () => {
|
||||
renderForm({ initialData: samplePdv })
|
||||
expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { PuntosDeVentaListPage } from '../../../features/puntos-de-venta/pages/PuntosDeVentaListPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
const adminWithPdv = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['administracion:puntos_de_venta:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const userWithoutPdv = {
|
||||
id: 2,
|
||||
username: 'cajero',
|
||||
nombre: 'Cajero',
|
||||
rol: 'cajero',
|
||||
permisos: [],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const mockMedios = [
|
||||
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
|
||||
]
|
||||
|
||||
function makePdvs(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
medioId: 1,
|
||||
numeroAFIP: i + 1,
|
||||
nombre: `PdV ${i + 1}`,
|
||||
activo: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage(user = adminWithPdv) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/puntos-de-venta']}>
|
||||
<Routes>
|
||||
<Route path="/admin/puntos-de-venta" element={<PuntosDeVentaListPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PuntosDeVentaListPage', () => {
|
||||
it('renders rows when API returns items', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
|
||||
HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument())
|
||||
expect(screen.getByText('PdV 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('PdV 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when items is empty', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
|
||||
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Nuevo punto de venta" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
|
||||
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage(userWithoutPdv)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /nuevo punto de venta/i })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows "Nuevo punto de venta" button when user has permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
|
||||
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /nuevo punto de venta/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('prev button disabled on first page', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/puntos-de-venta`, () =>
|
||||
HttpResponse.json({ items: makePdvs(3), page: 1, pageSize: 20, total: 3 }),
|
||||
),
|
||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('PdV 1')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
600
tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs
Normal file
600
tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,8 +47,9 @@ public class AuthControllerTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty");
|
||||
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
|
||||
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
||||
Assert.Equal(22, permisos.GetArrayLength());
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||
Assert.Equal(23, permisos.GetArrayLength());
|
||||
}
|
||||
|
||||
// Scenario: invalid credentials return 401 with opaque error
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With22Items()
|
||||
public async Task GetPermisos_WithAdmin_Returns200With23Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
@@ -138,8 +138,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
||||
Assert.Equal(22, list.GetArrayLength());
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||
Assert.Equal(23, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -182,7 +183,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With22Items()
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With23Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
@@ -190,8 +191,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
||||
Assert.Equal(22, list.GetArrayLength());
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||
Assert.Equal(23, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
138
tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs
Normal file
138
tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -79,8 +79,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
||||
var list = await _repository.ListAsync();
|
||||
|
||||
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
|
||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total
|
||||
Assert.Equal(22, list.Count);
|
||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
||||
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total
|
||||
Assert.Equal(23, list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
|
||||
{
|
||||
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total
|
||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' = 23 total
|
||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||
|
||||
Assert.Equal(22, permisos.Count);
|
||||
Assert.Equal(23, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ public class MedioRepositoryTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.PuntosDeVenta.Create;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.Create;
|
||||
|
||||
public class CreatePuntoDeVentaCommandHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly CreatePuntoDeVentaCommandHandler _handler;
|
||||
|
||||
private static Medio MakeMedio(int id = 5, bool activo = true)
|
||||
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
|
||||
|
||||
private static CreatePuntoDeVentaCommand ValidCommand() => new(
|
||||
MedioId: 5,
|
||||
NumeroAFIP: 1,
|
||||
Nombre: "PdV Central",
|
||||
Descripcion: null);
|
||||
|
||||
public CreatePuntoDeVentaCommandHandlerTests()
|
||||
{
|
||||
_handler = new CreatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5));
|
||||
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_repo.AddAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>()).Returns(10);
|
||||
}
|
||||
|
||||
// ── medio not found → throws ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns((Medio?)null);
|
||||
|
||||
await Assert.ThrowsAsync<MedioNotFoundException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
// ── medio inactivo → throws ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
|
||||
|
||||
await Assert.ThrowsAsync<MedioInactivoException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
// ── NumeroAFIP duplicado → throws ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException()
|
||||
{
|
||||
_repo.ExistsByNumeroAFIPInMedioAsync(5, 1, null, Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
await Assert.ThrowsAsync<NumeroAFIPDuplicadoException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NumeroAFIPDuplicado_DoesNotCallAddAsync()
|
||||
{
|
||||
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
try { await _handler.Handle(ValidCommand()); } catch (NumeroAFIPDuplicadoException) { }
|
||||
|
||||
await _repo.DidNotReceive().AddAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
Assert.Equal(10, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_DtoContainsCorrectFields()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
Assert.Equal(5, result.MedioId);
|
||||
Assert.Equal(1, result.NumeroAFIP);
|
||||
Assert.Equal("PdV Central", result.Nombre);
|
||||
Assert.True(result.Activo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsAuditWithCreateAction()
|
||||
{
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "punto_de_venta.create",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: "10",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── audit fail-closed ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditLoggerThrows_ExceptionBubblesUpAndAddNotCommitted()
|
||||
{
|
||||
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException(new InvalidOperationException("audit fail")));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.Deactivate;
|
||||
|
||||
public class DeactivatePuntoDeVentaCommandHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly DeactivatePuntoDeVentaCommandHandler _handler;
|
||||
|
||||
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true)
|
||||
=> new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null);
|
||||
|
||||
private static Medio MakeMedio(int id = 5, bool activo = true)
|
||||
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
|
||||
|
||||
public DeactivatePuntoDeVentaCommandHandlerTests()
|
||||
{
|
||||
_handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
|
||||
_medioRepo.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(MakeMedio(5, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
|
||||
|
||||
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
|
||||
() => _handler.Handle(new DeactivatePuntoDeVentaCommand(999)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
|
||||
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _repo.DidNotReceive().UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
|
||||
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ActivePdv_CallsUpdateAsyncWithInactiveEntity()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
|
||||
|
||||
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<PuntoDeVenta>(p => !p.Activo),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ActivePdv_WritesAuditWithDeactivateAction()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
|
||||
|
||||
await _handler.Handle(new DeactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "punto_de_venta.deactivate",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: "10",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.PuntosDeVenta.GetById;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.GetById;
|
||||
|
||||
public class GetPuntoDeVentaByIdQueryHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly GetPuntoDeVentaByIdQueryHandler _handler;
|
||||
|
||||
public GetPuntoDeVentaByIdQueryHandlerTests()
|
||||
{
|
||||
_handler = new GetPuntoDeVentaByIdQueryHandler(_repo);
|
||||
}
|
||||
|
||||
private static PuntoDeVenta MakePdv(int id = 5) =>
|
||||
new(id, 2, 3, "PdV " + id, "Desc", true, DateTime.UtcNow, null);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
|
||||
|
||||
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
|
||||
() => _handler.Handle(new GetPuntoDeVentaByIdQuery(999)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields()
|
||||
{
|
||||
var pdv = MakePdv(5);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(pdv);
|
||||
|
||||
var result = await _handler.Handle(new GetPuntoDeVentaByIdQuery(5));
|
||||
|
||||
Assert.Equal(5, result.Id);
|
||||
Assert.Equal(2, result.MedioId);
|
||||
Assert.Equal(3, result.NumeroAFIP);
|
||||
Assert.Equal("PdV 5", result.Nombre);
|
||||
Assert.Equal("Desc", result.Descripcion);
|
||||
Assert.True(result.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.PuntosDeVenta.List;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.List;
|
||||
|
||||
public class ListPuntosDeVentaQueryHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly ListPuntosDeVentaQueryHandler _handler;
|
||||
|
||||
public ListPuntosDeVentaQueryHandlerTests()
|
||||
{
|
||||
_handler = new ListPuntosDeVentaQueryHandler(_repo);
|
||||
}
|
||||
|
||||
private static PuntoDeVenta MakePdv(int id) =>
|
||||
new(id, 5, (short)id, "PdV " + id, null, true, DateTime.UtcNow, null);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPagedDtoItems()
|
||||
{
|
||||
var items = new List<PuntoDeVenta> { MakePdv(1), MakePdv(2) };
|
||||
var pagedResult = new PagedResult<PuntoDeVenta>(items, 1, 20, 2);
|
||||
|
||||
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(pagedResult);
|
||||
|
||||
var result = await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, null, null));
|
||||
|
||||
Assert.Equal(2, result.Total);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
Assert.Equal(1, result.Items[0].NumeroAFIP);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ClampsPageSizeToMax100()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<PuntoDeVenta>([], 1, 100, 0));
|
||||
|
||||
await _handler.Handle(new ListPuntosDeVentaQuery(1, 500, null, null));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<PuntosDeVentaQuery>(q => q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ClampsPageToMin1()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<PuntoDeVenta>([], 1, 20, 0));
|
||||
|
||||
await _handler.Handle(new ListPuntosDeVentaQuery(0, 20, null, null));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<PuntosDeVentaQuery>(q => q.Page == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FiltersByMedioId()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<PuntosDeVentaQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<PuntoDeVenta>([], 1, 20, 0));
|
||||
|
||||
await _handler.Handle(new ListPuntosDeVentaQuery(1, 20, MedioId: 5, null));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<PuntosDeVentaQuery>(q => q.MedioId == 5),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.Reactivate;
|
||||
|
||||
public class ReactivatePuntoDeVentaCommandHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly ReactivatePuntoDeVentaCommandHandler _handler;
|
||||
|
||||
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = false)
|
||||
=> new(id, medioId, 1, "PdV Test", null, activo, DateTime.UtcNow, null);
|
||||
|
||||
private static Medio MakeMedio(int id = 5, bool activo = true)
|
||||
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
|
||||
|
||||
public ReactivatePuntoDeVentaCommandHandlerTests()
|
||||
{
|
||||
_handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
|
||||
_medioRepo.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(MakeMedio(5, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
|
||||
|
||||
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
|
||||
() => _handler.Handle(new ReactivatePuntoDeVentaCommand(999)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
|
||||
|
||||
await Assert.ThrowsAsync<MedioInactivoException>(
|
||||
() => _handler.Handle(new ReactivatePuntoDeVentaCommand(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: true));
|
||||
|
||||
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _repo.DidNotReceive().UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_InactivePdv_CallsUpdateAsyncWithActiveEntity()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
|
||||
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<PuntoDeVenta>(p => p.Activo),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_InactivePdv_WritesAuditWithReactivateAction()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
|
||||
await _handler.Handle(new ReactivatePuntoDeVentaCommand(10));
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "punto_de_venta.reactivate",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: "10",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_NoAuditLogged()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10, activo: false));
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
|
||||
|
||||
try { await _handler.Handle(new ReactivatePuntoDeVentaCommand(10)); } catch (MedioInactivoException) { }
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.PuntosDeVenta.Update;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.PuntosDeVenta.Update;
|
||||
|
||||
public class UpdatePuntoDeVentaCommandHandlerTests
|
||||
{
|
||||
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
|
||||
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly UpdatePuntoDeVentaCommandHandler _handler;
|
||||
|
||||
private static PuntoDeVenta MakePdv(int id = 10, int medioId = 5, bool activo = true)
|
||||
=> new(id, medioId, 1, "Original", null, activo, DateTime.UtcNow, null);
|
||||
|
||||
private static Medio MakeMedio(int id = 5, bool activo = true)
|
||||
=> new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null);
|
||||
|
||||
private static UpdatePuntoDeVentaCommand ValidCommand(int id = 10) =>
|
||||
new(Id: id, Nombre: "Nuevo Nombre", NumeroAFIP: 3, Descripcion: null);
|
||||
|
||||
public UpdatePuntoDeVentaCommandHandlerTests()
|
||||
{
|
||||
_handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(MakePdv(10));
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5));
|
||||
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any<int>(), Arg.Any<short>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsPuntoDeVentaNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((PuntoDeVenta?)null);
|
||||
|
||||
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
|
||||
() => _handler.Handle(new UpdatePuntoDeVentaCommand(999, "X", 1, null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
|
||||
|
||||
await Assert.ThrowsAsync<MedioInactivoException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NumeroAFIPDuplicado_ThrowsNumeroAFIPDuplicadoException()
|
||||
{
|
||||
_repo.ExistsByNumeroAFIPInMedioAsync(5, 3, 10, Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
await Assert.ThrowsAsync<NumeroAFIPDuplicadoException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsUpdateAsyncOnce()
|
||||
{
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
await _repo.Received(1).UpdateAsync(Arg.Any<PuntoDeVenta>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
Assert.Equal(10, result.Id);
|
||||
Assert.Equal("Nuevo Nombre", result.Nombre);
|
||||
Assert.Equal(3, result.NumeroAFIP);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsAuditWithUpdateAction()
|
||||
{
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "punto_de_venta.update",
|
||||
targetType: "PuntoDeVenta",
|
||||
targetId: "10",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_NoAuditLogged()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeMedio(5, false));
|
||||
|
||||
try { await _handler.Handle(ValidCommand()); } catch (MedioInactivoException) { }
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
|
||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
|
||||
await EnsureV011SchemaAsync();
|
||||
|
||||
// V013 (ADM-008): ensure dbo.PuntoDeVenta + 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
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -56,6 +61,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -165,7 +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:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'),
|
||||
-- V011 (ADM-001): permiso para CRUD de Secciones
|
||||
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion')
|
||||
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'),
|
||||
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
|
||||
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -207,6 +215,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'administracion:permisos:ver'),
|
||||
-- V011 (ADM-001)
|
||||
('admin', 'administracion:secciones:gestionar'),
|
||||
-- V013 (ADM-008)
|
||||
('admin', 'administracion:puntos_de_venta:gestionar'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
@@ -373,6 +383,104 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// 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>
|
||||
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user