Compare commits
19 Commits
b7ac9831f9
...
a82d51ff7a
| Author | SHA1 | Date | |
|---|---|---|---|
| a82d51ff7a | |||
| fc77576427 | |||
| 6458ee0106 | |||
| 6be637b4cf | |||
| 7d432a949a | |||
| 40482caf7b | |||
| 9263d9a178 | |||
| 4368c42599 | |||
| 65787db272 | |||
| 4720f6772f | |||
| 056045232c | |||
| 4b96cdefcc | |||
| d61292afa4 | |||
| 48779543f9 | |||
| 39160bbb83 | |||
| 489359f0b8 | |||
| 50f6f2b67a | |||
| 43877bd4a1 | |||
| bef8977c5c |
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;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "punto_de_venta_not_found",
|
||||||
|
message = puntoDeVentaNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "numero_afip_duplicado",
|
||||||
|
message = numeroAFIPDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
// UDT-009: permiso override validation errors
|
// UDT-009: permiso override validation errors
|
||||||
case InvalidPermisoCodesException ipce:
|
case InvalidPermisoCodesException ipce:
|
||||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||||
|
|||||||
@@ -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.Get;
|
||||||
using SIGCM2.Application.Roles.List;
|
using SIGCM2.Application.Roles.List;
|
||||||
using SIGCM2.Application.Roles.Update;
|
using SIGCM2.Application.Roles.Update;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Create;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.GetById;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.List;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Update;
|
||||||
using SIGCM2.Application.Secciones.Create;
|
using SIGCM2.Application.Secciones.Create;
|
||||||
using SIGCM2.Application.Secciones.Deactivate;
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
using SIGCM2.Application.Secciones.GetById;
|
using SIGCM2.Application.Secciones.GetById;
|
||||||
@@ -90,6 +96,14 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
||||||
|
|
||||||
|
// Puntos de Venta (ADM-008)
|
||||||
|
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -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<IRolPermisoRepository, RolPermisoRepository>();
|
||||||
services.AddScoped<IMedioRepository, MedioRepository>();
|
services.AddScoped<IMedioRepository, MedioRepository>();
|
||||||
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
||||||
|
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -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,
|
PanelLeftOpen,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Columns3,
|
Columns3,
|
||||||
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -61,6 +62,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Columns3,
|
icon: Columns3,
|
||||||
requiredPermission: 'administracion:secciones:gestionar',
|
requiredPermission: 'administracion:secciones:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Puntos de Venta',
|
||||||
|
href: '/admin/puntos-de-venta',
|
||||||
|
icon: Store,
|
||||||
|
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
|
|||||||
@@ -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 { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage'
|
||||||
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
|
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
|
||||||
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage'
|
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage'
|
||||||
|
import { PuntosDeVentaListPage } from './features/puntos-de-venta/pages/PuntosDeVentaListPage'
|
||||||
|
import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage'
|
||||||
|
import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage'
|
||||||
|
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -240,6 +244,40 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Puntos de Venta routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/puntos-de-venta"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||||
|
<PuntosDeVentaListPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/puntos-de-venta/nuevo"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||||
|
<CreatePuntoDeVentaPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/puntos-de-venta/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||||
|
<PuntoDeVentaDetailPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/puntos-de-venta/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:puntos_de_venta:gestionar']}>
|
||||||
|
<EditPuntoDeVentaPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
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(nombre.GetString()), "'usuario.nombre' must not be empty");
|
||||||
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
|
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
|
||||||
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
Assert.Equal(22, permisos.GetArrayLength());
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||||
|
Assert.Equal(23, permisos.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: invalid credentials return 401 with opaque error
|
// Scenario: invalid credentials return 401 with opaque error
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPermisos_WithAdmin_Returns200With22Items()
|
public async Task GetPermisos_WithAdmin_Returns200With23Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||||
@@ -138,8 +138,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
Assert.Equal(22, list.GetArrayLength());
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||||
|
Assert.Equal(23, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -182,7 +183,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRolPermisos_AdminRol_Returns200With22Items()
|
public async Task GetRolPermisos_AdminRol_Returns200With23Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||||
@@ -190,8 +191,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 total
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
Assert.Equal(22, list.GetArrayLength());
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
||||||
|
Assert.Equal(23, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
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.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
|||||||
var list = await _repository.ListAsync();
|
var list = await _repository.ListAsync();
|
||||||
|
|
||||||
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
|
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
|
||||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total
|
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
||||||
Assert.Equal(22, list.Count);
|
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' = 23 total
|
||||||
|
Assert.Equal(23, list.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -177,10 +177,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
|||||||
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
|
public async Task GetByRolCodigoAsync_Admin_Returns22Permisos()
|
||||||
{
|
{
|
||||||
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total
|
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||||
|
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' = 23 total
|
||||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||||
|
|
||||||
Assert.Equal(22, permisos.Count);
|
Assert.Equal(23, permisos.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
|||||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
|
|||||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
|
|||||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ public class MedioRepositoryTests : IAsyncLifetime
|
|||||||
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
// ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado).
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
|
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
|
||||||
await EnsureV011SchemaAsync();
|
await EnsureV011SchemaAsync();
|
||||||
|
|
||||||
|
// V013 (ADM-008): ensure dbo.PuntoDeVenta + temporal + permiso. Drops idempotentes
|
||||||
|
// de SecuenciaComprobante + SP usp_ReservarNumeroComprobante (cirugía post-smoke:
|
||||||
|
// IMAC asigna numeros AFIP, no nosotros — ver memoria architecture/facturacion-imac-numeracion).
|
||||||
|
await EnsureV013SchemaAsync();
|
||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
@@ -56,6 +61,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Medio_History"),
|
new Respawn.Graph.Table("dbo", "Medio_History"),
|
||||||
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
new Respawn.Graph.Table("dbo", "Seccion_History"),
|
||||||
|
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,7 +171,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
|
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
|
||||||
('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'),
|
('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'),
|
||||||
-- V011 (ADM-001): permiso para CRUD de Secciones
|
-- V011 (ADM-001): permiso para CRUD de Secciones
|
||||||
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion')
|
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'),
|
||||||
|
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
|
||||||
|
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion')
|
||||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||||
ON t.Codigo = s.Codigo
|
ON t.Codigo = s.Codigo
|
||||||
WHEN NOT MATCHED BY TARGET THEN
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
@@ -207,6 +215,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
('admin', 'administracion:permisos:ver'),
|
('admin', 'administracion:permisos:ver'),
|
||||||
-- V011 (ADM-001)
|
-- V011 (ADM-001)
|
||||||
('admin', 'administracion:secciones:gestionar'),
|
('admin', 'administracion:secciones:gestionar'),
|
||||||
|
-- V013 (ADM-008)
|
||||||
|
('admin', 'administracion:puntos_de_venta:gestionar'),
|
||||||
('cajero', 'ventas:contado:crear'),
|
('cajero', 'ventas:contado:crear'),
|
||||||
('cajero', 'ventas:contado:modificar'),
|
('cajero', 'ventas:contado:modificar'),
|
||||||
('cajero', 'ventas:contado:cobrar'),
|
('cajero', 'ventas:contado:cobrar'),
|
||||||
@@ -373,6 +383,104 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-008 (V013): applies dbo.PuntoDeVenta schema + temporal table.
|
||||||
|
/// NOTE: SecuenciaComprobante y SP usp_ReservarNumeroComprobante fueron eliminados
|
||||||
|
/// post-smoke (Batch 9) — IMAC/Infogestión asigna los números AFIP externamente.
|
||||||
|
/// Este método también hace DROP idempotente de esos artefactos en caso de que
|
||||||
|
/// SIGCM2_Test los tuviera de una versión previa de la migración V013.
|
||||||
|
/// Permiso y asignación se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureV013SchemaAsync()
|
||||||
|
{
|
||||||
|
// ── Drops idempotentes de artefactos eliminados (cirugía post-smoke) ──
|
||||||
|
// Si SIGCM2_Test tiene SecuenciaComprobante o el SP de una versión previa, se limpian.
|
||||||
|
const string dropSp = """
|
||||||
|
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
|
||||||
|
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string disableSecuenciaVersioning = """
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
|
||||||
|
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string dropSecuenciaHistory = """
|
||||||
|
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
|
||||||
|
DROP TABLE dbo.SecuenciaComprobante_History;
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string dropSecuencia = """
|
||||||
|
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
|
||||||
|
DROP TABLE dbo.SecuenciaComprobante;
|
||||||
|
""";
|
||||||
|
|
||||||
|
// ── PuntoDeVenta: crear si no existe ──────────────────────────────────
|
||||||
|
const string createPdv = """
|
||||||
|
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.PuntoDeVenta (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY,
|
||||||
|
MedioId INT NOT NULL,
|
||||||
|
NumeroAFIP SMALLINT NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(255) NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP),
|
||||||
|
CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createPdvIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_PuntoDeVenta_MedioId_Activo
|
||||||
|
ON dbo.PuntoDeVenta(MedioId, Activo)
|
||||||
|
INCLUDE (NumeroAFIP, Nombre);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string addPdvPeriod = """
|
||||||
|
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string setPdvVersioning = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.PuntoDeVenta_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Drops primero (limpieza de versión previa)
|
||||||
|
await _connection.ExecuteAsync(dropSp);
|
||||||
|
await _connection.ExecuteAsync(disableSecuenciaVersioning);
|
||||||
|
await _connection.ExecuteAsync(dropSecuenciaHistory);
|
||||||
|
await _connection.ExecuteAsync(dropSecuencia);
|
||||||
|
|
||||||
|
// Luego crear PuntoDeVenta + Temporal Table
|
||||||
|
await _connection.ExecuteAsync(createPdv);
|
||||||
|
await _connection.ExecuteAsync(createPdvIndex);
|
||||||
|
await _connection.ExecuteAsync(addPdvPeriod);
|
||||||
|
await _connection.ExecuteAsync(setPdvVersioning);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
|
/// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user