Compare commits
27 Commits
da063ad677
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a231c206e | |||
| bcb0c94fc5 | |||
| 2aae873a4b | |||
| 3a534f7ad3 | |||
| dfeb5fb7e1 | |||
| 3e7c4bfde9 | |||
| 0eab947975 | |||
| ee36d86b5a | |||
| 0e2e4c9c94 | |||
| 3a596080cb | |||
| d7c6cbd4ff | |||
| 40b5f3904a | |||
| 3eecb05634 | |||
| f7fb76219a | |||
| 5c1675e59a | |||
| 5175cc1ece | |||
| c2a0612a70 | |||
| 8fc7b363d5 | |||
| 3b1edfd696 | |||
| f1b38cd9ce | |||
| ded76fcdc7 | |||
| 8ac91a13aa | |||
| 9144c2e89e | |||
| dd4d4a1673 | |||
| e997409e95 | |||
| 34b07a1d55 | |||
| 0dce3ee4ac |
@@ -34,6 +34,14 @@ database/
|
|||||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||||
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
||||||
|
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
|
||||||
|
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
|
||||||
|
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
|
||||||
|
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
|
||||||
|
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
||||||
|
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
||||||
|
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
||||||
|
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
|
|||||||
33
database/migrations/V020_ROLLBACK.sql
Normal file
33
database/migrations/V020_ROLLBACK.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- V020_ROLLBACK.sql
|
||||||
|
-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql.
|
||||||
|
--
|
||||||
|
-- Pasos:
|
||||||
|
-- 1. Elimina la asignación del permiso al rol 'admin'.
|
||||||
|
-- 2. Elimina el permiso del catálogo.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente,
|
||||||
|
-- la FK de RolPermiso causará error. Limpiar RolPermiso primero.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 1. Eliminar asignaciones del permiso a cualquier rol.
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar';
|
||||||
|
PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 2. Eliminar el permiso del catálogo.
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'tasacion:caracteres_especiales:gestionar';
|
||||||
|
PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V020 rollback complete.';
|
||||||
|
GO
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- V020__add_chargeable_chars_permission.sql
|
||||||
|
-- PRC-001: permiso RBAC para ABM de caracteres tasables.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo.
|
||||||
|
-- 2. Asigna el permiso al rol 'admin'.
|
||||||
|
--
|
||||||
|
-- Convención RBAC: modulo:recurso:accion.
|
||||||
|
-- Patrón: V007 (MERGE idempotente).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V020_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir
|
||||||
|
-- antes de que la API arranque con [RequirePermission(...)].
|
||||||
|
-- V021 crea la tabla dbo.ChargeableCharConfig.
|
||||||
|
-- V022 siembra las 4 filas globales por defecto.
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17)
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Agregar permiso al catálogo (idempotente via MERGE).
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('tasacion:caracteres_especiales:gestionar',
|
||||||
|
N'Gestionar caracteres tasables',
|
||||||
|
N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.',
|
||||||
|
'tasacion')
|
||||||
|
) 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
|
||||||
|
|
||||||
|
-- Asignar a rol 'admin' (idempotente via MERGE).
|
||||||
|
MERGE dbo.RolPermiso AS t
|
||||||
|
USING (
|
||||||
|
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||||
|
FROM dbo.Rol r
|
||||||
|
CROSS JOIN dbo.Permiso p
|
||||||
|
WHERE r.Codigo = 'admin'
|
||||||
|
AND p.Codigo = 'tasacion:caracteres_especiales:gestionar'
|
||||||
|
) 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 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.';
|
||||||
|
GO
|
||||||
79
database/migrations/V021_ROLLBACK.sql
Normal file
79
database/migrations/V021_ROLLBACK.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- V021_ROLLBACK.sql
|
||||||
|
-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
|
||||||
|
--
|
||||||
|
-- Pasos:
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
|
||||||
|
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
||||||
|
-- 3. Drop de dbo.ChargeableCharConfig_History.
|
||||||
|
-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
|
||||||
|
-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
|
||||||
|
-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 2. Elimina el PERIOD y las hidden cols.
|
||||||
|
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
|
||||||
|
PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 3. Drop de la history table.
|
||||||
|
IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ChargeableCharConfig_History;
|
||||||
|
PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 4. Drop de la tabla principal.
|
||||||
|
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ChargeableCharConfig;
|
||||||
|
PRINT 'Table dbo.ChargeableCharConfig dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 5. Drop del SP InsertWithClose.
|
||||||
|
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||||
|
PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 6. Drop del SP GetActiveForMedio.
|
||||||
|
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
||||||
|
PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
|
||||||
|
GO
|
||||||
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
-- V021__create_chargeable_char_config.sql
|
||||||
|
-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
|
||||||
|
-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
|
||||||
|
-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
|
||||||
|
-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
|
||||||
|
--
|
||||||
|
-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V021_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
|
||||||
|
-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
|
||||||
|
-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
|
||||||
|
-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
|
||||||
|
-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
|
||||||
|
-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.ChargeableCharConfig
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ChargeableCharConfig (
|
||||||
|
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||||
|
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
|
||||||
|
MedioId INT NULL, -- NULL = global fallback
|
||||||
|
Symbol NVARCHAR(4) NOT NULL,
|
||||||
|
Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
|
||||||
|
PricePerUnit DECIMAL(18,4) NOT NULL,
|
||||||
|
ValidFrom DATE NOT NULL,
|
||||||
|
ValidTo DATE NULL,
|
||||||
|
IsActive BIT NOT NULL
|
||||||
|
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL
|
||||||
|
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
CONSTRAINT FK_ChargeableCharConfig_Medio
|
||||||
|
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
|
||||||
|
CHECK (PricePerUnit > 0),
|
||||||
|
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
|
||||||
|
CHECK (LEN(Symbol) > 0),
|
||||||
|
CONSTRAINT CK_ChargeableCharConfig_ValidRange
|
||||||
|
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.ChargeableCharConfig created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
|
||||||
|
-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD
|
||||||
|
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||||
|
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||||
|
PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_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 = 'ChargeableCharConfig_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Índices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Un único vigente por (MedioId, Symbol).
|
||||||
|
-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
|
||||||
|
-- → enforza exactamente 1 vigente global por símbolo (D7).
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
|
||||||
|
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
||||||
|
WHERE ValidTo IS NULL;
|
||||||
|
PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Cover para GetActiveForMedio y List.
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ChargeableCharConfig_Query
|
||||||
|
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
||||||
|
INCLUDE (PricePerUnit, IsActive, Category);
|
||||||
|
PRINT 'Index IX_ChargeableCharConfig_Query created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||||
|
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||||
|
-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
|
||||||
|
-- THROW 50404: Medio not found.
|
||||||
|
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
||||||
|
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GO
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||||
|
@MedioId INT = NULL,
|
||||||
|
@Symbol NVARCHAR(4),
|
||||||
|
@Category NVARCHAR(32),
|
||||||
|
@PricePerUnit DECIMAL(18,4),
|
||||||
|
@ValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
|
||||||
|
IF @MedioId IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'Medio not found', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Lee el vigente actual con bloqueo de rango para serialización.
|
||||||
|
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||||
|
SELECT TOP 1
|
||||||
|
@ActiveId = Id,
|
||||||
|
@ActiveValidFrom = ValidFrom
|
||||||
|
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
||||||
|
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
||||||
|
AND Symbol = @Symbol
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
|
||||||
|
-- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
|
||||||
|
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
-- Inserta el nuevo vigente.
|
||||||
|
INSERT INTO dbo.ChargeableCharConfig
|
||||||
|
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES
|
||||||
|
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||||
|
-- Resolución per-medio + global fallback: 1 fila por Symbol.
|
||||||
|
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GO
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||||
|
@MedioId INT,
|
||||||
|
@AsOfDate DATE
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
WITH Candidates AS (
|
||||||
|
SELECT
|
||||||
|
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY Symbol
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global
|
||||||
|
ValidFrom DESC
|
||||||
|
) AS rn
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE IsActive = 1
|
||||||
|
AND ValidFrom <= @AsOfDate
|
||||||
|
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||||
|
AND (MedioId = @MedioId OR MedioId IS NULL)
|
||||||
|
)
|
||||||
|
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM Candidates
|
||||||
|
WHERE rn = 1;
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
|
||||||
|
PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
|
||||||
|
GO
|
||||||
23
database/migrations/V022_ROLLBACK.sql
Normal file
23
database/migrations/V022_ROLLBACK.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- V022_ROLLBACK.sql
|
||||||
|
-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql.
|
||||||
|
--
|
||||||
|
-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL).
|
||||||
|
-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora
|
||||||
|
-- (ya no son vigentes). La historia temporal queda intacta.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE MedioId IS NULL
|
||||||
|
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.';
|
||||||
|
GO
|
||||||
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- V022__seed_chargeable_char_config.sql
|
||||||
|
-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000.
|
||||||
|
-- El equipo de negocio seteará los valores reales desde el CMS.
|
||||||
|
--
|
||||||
|
-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V022_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- Depends on: V021 (dbo.ChargeableCharConfig must exist).
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista
|
||||||
|
-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio).
|
||||||
|
-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026.
|
||||||
|
-- - ValidTo NULL = vigente (sin fecha de cierre).
|
||||||
|
-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación.
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3)
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||||
|
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||||
|
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||||
|
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
|
||||||
|
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).';
|
||||||
|
PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.';
|
||||||
|
GO
|
||||||
246
database/migrations/V023_ROLLBACK.sql
Normal file
246
database/migrations/V023_ROLLBACK.sql
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
-- V023_ROLLBACK.sql
|
||||||
|
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
|
||||||
|
-- - Todos los datos de ProductTypeId se pierden.
|
||||||
|
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
|
||||||
|
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
|
||||||
|
--
|
||||||
|
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
||||||
|
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
|
||||||
|
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||||
|
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
AND name = 'ProductTypeId')
|
||||||
|
BEGIN
|
||||||
|
-- 2a. Turn off SYSTEM_VERSIONING
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
|
||||||
|
|
||||||
|
-- 2b. Drop indexes on ProductTypeId
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
||||||
|
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
||||||
|
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- 2c. Drop FK to ProductType
|
||||||
|
DECLARE @fk_pt sysname;
|
||||||
|
SELECT @fk_pt = name
|
||||||
|
FROM sys.foreign_keys
|
||||||
|
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
|
||||||
|
IF @fk_pt IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
|
||||||
|
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- 2d. Drop NonNegative price check; restore Positive check
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
||||||
|
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
||||||
|
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
|
||||||
|
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
||||||
|
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
||||||
|
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
|
||||||
|
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- 2e. Drop ProductTypeId column from main + history
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
|
||||||
|
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||||
|
AND name = 'ProductTypeId')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
|
||||||
|
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- 2f. Restore MedioId column
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
|
||||||
|
PRINT 'V023 ROLLBACK: MedioId restored.';
|
||||||
|
|
||||||
|
-- 2g. Restore FK to Medio
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
|
||||||
|
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
|
||||||
|
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
|
||||||
|
|
||||||
|
-- 2h. Restore indexes on MedioId
|
||||||
|
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
||||||
|
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
||||||
|
WHERE ValidTo IS NULL;
|
||||||
|
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
||||||
|
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
||||||
|
INCLUDE (PricePerUnit, IsActive, Category);
|
||||||
|
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
|
||||||
|
|
||||||
|
-- 2i. Restore SYSTEM_VERSIONING
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
|
||||||
|
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
|
||||||
|
GO
|
||||||
|
|
||||||
|
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||||
|
@MedioId INT = NULL,
|
||||||
|
@Symbol NVARCHAR(4),
|
||||||
|
@Category NVARCHAR(32),
|
||||||
|
@PricePerUnit DECIMAL(18,4),
|
||||||
|
@ValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
IF @MedioId IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'Medio not found', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||||
|
SELECT TOP 1
|
||||||
|
@ActiveId = Id,
|
||||||
|
@ActiveValidFrom = ValidFrom
|
||||||
|
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
||||||
|
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
||||||
|
AND Symbol = @Symbol
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
|
||||||
|
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
INSERT INTO dbo.ChargeableCharConfig
|
||||||
|
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES
|
||||||
|
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
|
||||||
|
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
|
||||||
|
GO
|
||||||
|
|
||||||
|
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||||
|
@MedioId INT,
|
||||||
|
@AsOfDate DATE
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
WITH Candidates AS (
|
||||||
|
SELECT
|
||||||
|
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY Symbol
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
|
||||||
|
ValidFrom DESC
|
||||||
|
) AS rn
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE IsActive = 1
|
||||||
|
AND ValidFrom <= @AsOfDate
|
||||||
|
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||||
|
AND (MedioId = @MedioId OR MedioId IS NULL)
|
||||||
|
)
|
||||||
|
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM Candidates
|
||||||
|
WHERE rn = 1;
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
|
||||||
|
GO
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
-- V023__refactor_chargeable_char_config_to_product_type.sql
|
||||||
|
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
|
||||||
|
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
|
||||||
|
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
|
||||||
|
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
|
||||||
|
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
|
||||||
|
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
|
||||||
|
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
|
||||||
|
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
|
||||||
|
--
|
||||||
|
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
|
||||||
|
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
|
||||||
|
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
|
||||||
|
-- Depende de: V017 (dbo.ProductType debe existir).
|
||||||
|
-- Reversa: V023_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
||||||
|
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
|
||||||
|
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
AND name = 'MedioId')
|
||||||
|
BEGIN
|
||||||
|
PRINT 'V023: MedioId column found — proceeding with refactor.';
|
||||||
|
|
||||||
|
-- ─── 1. Turn OFF SYSTEM_VERSIONING (idempotent — skip if already OFF) ───
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables
|
||||||
|
WHERE name = 'ChargeableCharConfig'
|
||||||
|
AND schema_id = SCHEMA_ID('dbo')
|
||||||
|
AND temporal_type = 2) -- 2 = SYSTEM_VERSIONED_TEMPORAL_TABLE
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V023: SYSTEM_VERSIONING already OFF — skipping.';
|
||||||
|
|
||||||
|
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
||||||
|
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||||
|
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
||||||
|
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
|
||||||
|
DECLARE @fk_name sysname;
|
||||||
|
SELECT @fk_name = name
|
||||||
|
FROM sys.foreign_keys
|
||||||
|
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
AND referenced_object_id = OBJECT_ID('dbo.Medio');
|
||||||
|
IF @fk_name IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
|
||||||
|
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
|
||||||
|
DECLARE @df_medio sysname;
|
||||||
|
SELECT @df_medio = dc.name
|
||||||
|
FROM sys.default_constraints dc
|
||||||
|
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
||||||
|
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||||
|
AND c.name = 'MedioId';
|
||||||
|
IF @df_medio IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
|
||||||
|
PRINT 'V023: Default constraint on MedioId dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
|
||||||
|
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
|
||||||
|
|
||||||
|
-- Drop MedioId from history table if present
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||||
|
AND name = 'MedioId')
|
||||||
|
BEGIN
|
||||||
|
-- Drop default constraint on history MedioId if any
|
||||||
|
DECLARE @df_hist_medio sysname;
|
||||||
|
SELECT @df_hist_medio = dc.name
|
||||||
|
FROM sys.default_constraints dc
|
||||||
|
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
||||||
|
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||||
|
AND c.name = 'MedioId';
|
||||||
|
IF @df_hist_medio IS NOT NULL
|
||||||
|
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
|
||||||
|
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
|
||||||
|
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
||||||
|
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
||||||
|
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
|
||||||
|
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
||||||
|
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
||||||
|
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
|
||||||
|
CHECK (PricePerUnit >= 0);
|
||||||
|
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD ProductTypeId INT NULL; -- NULL = global fallback
|
||||||
|
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig_History
|
||||||
|
ADD ProductTypeId INT NULL;
|
||||||
|
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
|
||||||
|
|
||||||
|
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
|
||||||
|
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
|
||||||
|
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
|
||||||
|
|
||||||
|
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
|
||||||
|
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
|
||||||
|
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
|
||||||
|
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
||||||
|
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
|
||||||
|
WHERE ValidTo IS NULL;
|
||||||
|
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
|
||||||
|
|
||||||
|
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
|
||||||
|
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
||||||
|
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
|
||||||
|
INCLUDE (PricePerUnit, IsActive, Category);
|
||||||
|
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
|
||||||
|
|
||||||
|
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
|
||||||
|
ALTER TABLE dbo.ChargeableCharConfig
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
|
||||||
|
ELSE
|
||||||
|
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
|
||||||
|
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||||
|
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
|
||||||
|
-- THROW 50404: ProductType not found.
|
||||||
|
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
||||||
|
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||||
|
@ProductTypeId INT = NULL,
|
||||||
|
@Symbol NVARCHAR(4),
|
||||||
|
@Category NVARCHAR(32),
|
||||||
|
@PricePerUnit DECIMAL(18,4),
|
||||||
|
@ValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
|
||||||
|
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
|
||||||
|
IF @ProductTypeId IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'ProductType not found', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Read current vigente with range lock for serialization.
|
||||||
|
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||||
|
SELECT TOP 1
|
||||||
|
@ActiveId = Id,
|
||||||
|
@ActiveValidFrom = ValidFrom
|
||||||
|
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
|
||||||
|
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
|
||||||
|
AND Symbol = @Symbol
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
|
||||||
|
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
|
||||||
|
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
-- Insert the new vigente.
|
||||||
|
INSERT INTO dbo.ChargeableCharConfig
|
||||||
|
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES
|
||||||
|
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
||||||
|
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
|
||||||
|
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
|
||||||
|
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
|
||||||
|
-- @ProductTypeId: the specific product type to resolve for.
|
||||||
|
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
|
||||||
|
@ProductTypeId INT,
|
||||||
|
@AsOfDate DATE
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
WITH Candidates AS (
|
||||||
|
SELECT
|
||||||
|
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY Symbol
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global
|
||||||
|
ValidFrom DESC
|
||||||
|
) AS rn
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE IsActive = 1
|
||||||
|
AND ValidFrom <= @AsOfDate
|
||||||
|
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||||
|
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
|
||||||
|
)
|
||||||
|
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM Candidates
|
||||||
|
WHERE rn = 1;
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
|
||||||
|
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
|
||||||
|
-- Guards:
|
||||||
|
-- - Row must exist → THROW 50404
|
||||||
|
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
|
||||||
|
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
|
||||||
|
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
|
||||||
|
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
|
||||||
|
-- Preserves forward-only invariant and maintains clean history.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
||||||
|
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
|
||||||
|
@Id BIGINT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Step 1: Lock + load target row.
|
||||||
|
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
|
||||||
|
SELECT @ProductTypeId = ProductTypeId,
|
||||||
|
@Symbol = Symbol,
|
||||||
|
@ValidTo = ValidTo,
|
||||||
|
@IsActive = IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
|
||||||
|
WHERE Id = @Id;
|
||||||
|
|
||||||
|
IF @@ROWCOUNT = 0
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK TRANSACTION;
|
||||||
|
THROW 50404, 'ChargeableCharConfig row not found', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
|
||||||
|
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
|
||||||
|
IF @ValidTo IS NULL
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK TRANSACTION;
|
||||||
|
THROW 50410, 'Row is already active — reactivation not needed', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
|
||||||
|
-- Prevents re-opening a row while another is already vigente.
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
||||||
|
AND Symbol = @Symbol
|
||||||
|
AND ValidTo IS NULL
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK TRANSACTION;
|
||||||
|
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
|
||||||
|
-- Ensures this is the LAST closed row; reactivating an older row would violate
|
||||||
|
-- forward-only ordering of the temporal chain.
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
||||||
|
AND Symbol = @Symbol
|
||||||
|
AND ValidFrom > @ValidTo
|
||||||
|
AND Id <> @Id
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK TRANSACTION;
|
||||||
|
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Step 5: Literal undo — re-open the row.
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET IsActive = 1,
|
||||||
|
ValidTo = NULL
|
||||||
|
WHERE Id = @Id;
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
|
||||||
|
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
|
||||||
|
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
|
||||||
|
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
|
||||||
|
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
|
||||||
|
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
|
||||||
|
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
|
||||||
|
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
|
||||||
|
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
|
||||||
|
GO
|
||||||
22
database/migrations/V024_ROLLBACK.sql
Normal file
22
database/migrations/V024_ROLLBACK.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- V024_ROLLBACK.sql
|
||||||
|
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
|
||||||
|
--
|
||||||
|
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
|
||||||
|
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
|
||||||
|
--
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
|
||||||
|
WHERE ProductTypeId IS NULL
|
||||||
|
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
|
||||||
|
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
|
||||||
|
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
||||||
|
GO
|
||||||
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- V024__reseed_global_with_zero_price.sql
|
||||||
|
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
|
||||||
|
--
|
||||||
|
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
|
||||||
|
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
|
||||||
|
-- - No existe historial de facturación con el valor 1.0000.
|
||||||
|
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
|
||||||
|
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
|
||||||
|
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
|
||||||
|
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
|
||||||
|
--
|
||||||
|
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
|
||||||
|
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
|
||||||
|
-- Reversa: V024_ROLLBACK.sql.
|
||||||
|
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
|
||||||
|
WHERE ProductTypeId IS NULL
|
||||||
|
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||||
|
AND ValidTo IS NULL;
|
||||||
|
|
||||||
|
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
|
||||||
|
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
||||||
|
GO
|
||||||
20
database/migrations/V025_ROLLBACK.sql
Normal file
20
database/migrations/V025_ROLLBACK.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- V025_ROLLBACK.sql
|
||||||
|
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
|
||||||
|
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE ProductTypeId IS NOT NULL
|
||||||
|
AND ValidTo IS NULL
|
||||||
|
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||||
|
AND ProductTypeId IN (
|
||||||
|
SELECT Id FROM dbo.ProductType
|
||||||
|
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
|
||||||
|
);
|
||||||
|
|
||||||
|
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
|
||||||
|
GO
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- V025__seed_chargeable_char_overrides_demo.sql
|
||||||
|
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
|
||||||
|
--
|
||||||
|
-- Estrategia:
|
||||||
|
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
|
||||||
|
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
|
||||||
|
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
|
||||||
|
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
|
||||||
|
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
|
||||||
|
-- los overrides que falten (MERGE idempotente).
|
||||||
|
--
|
||||||
|
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
|
||||||
|
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
|
||||||
|
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
|
||||||
|
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
|
||||||
|
--
|
||||||
|
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
|
||||||
|
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||||
|
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||||
|
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Clasificado overrides
|
||||||
|
IF @ClasificadoId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Notables overrides
|
||||||
|
IF @NotablesId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||||
|
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||||
|
|
||||||
|
-- Fúnebres overrides
|
||||||
|
IF @FunebresId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
MERGE dbo.ChargeableCharConfig AS t
|
||||||
|
USING (VALUES
|
||||||
|
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||||
|
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||||
|
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||||
|
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||||
|
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||||
|
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
|
||||||
|
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
|
||||||
|
GO
|
||||||
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
|
||||||
|
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
|
||||||
|
/// Route base: api/v1/admin/chargeable-chars
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/chargeable-chars")]
|
||||||
|
public sealed class ChargeableCharConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
|
||||||
|
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
|
||||||
|
|
||||||
|
public ChargeableCharConfigController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateChargeableCharConfigCommand> createValidator,
|
||||||
|
IValidator<SchedulePriceChangeCommand> scheduleValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_scheduleValidator = scheduleValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a paginated list of ChargeableCharConfig rows.
|
||||||
|
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
|
||||||
|
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
|
||||||
|
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] long? productTypeId,
|
||||||
|
[FromQuery] bool activeOnly = true,
|
||||||
|
[FromQuery] int? page = null,
|
||||||
|
[FromQuery] int? pageSize = null,
|
||||||
|
[FromQuery] int? skip = null,
|
||||||
|
[FromQuery] int? take = null)
|
||||||
|
{
|
||||||
|
// Support both page/pageSize and skip/take query patterns
|
||||||
|
int resolvedPage;
|
||||||
|
int resolvedPageSize;
|
||||||
|
|
||||||
|
if (skip is not null || take is not null)
|
||||||
|
{
|
||||||
|
// Convert skip/take to page/pageSize
|
||||||
|
resolvedPageSize = Math.Min(take ?? 50, 200);
|
||||||
|
resolvedPage = resolvedPageSize > 0
|
||||||
|
? ((skip ?? 0) / resolvedPageSize) + 1
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resolvedPage = page ?? 1;
|
||||||
|
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
|
||||||
|
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:long}")]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetById([FromRoute] long id)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
|
||||||
|
new GetChargeableCharConfigByIdQuery(id));
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
|
||||||
|
/// Returns 201 Created with Location header pointing to GET /{id}.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateChargeableCharConfigCommand(
|
||||||
|
request.ProductTypeId,
|
||||||
|
request.Symbol,
|
||||||
|
request.Category,
|
||||||
|
request.PricePerUnit,
|
||||||
|
request.ValidFrom);
|
||||||
|
|
||||||
|
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<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules a price change for an existing ChargeableCharConfig.
|
||||||
|
/// Closes the current active row and opens a new one with the new price + ValidFrom.
|
||||||
|
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("{id:long}/price")]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> SchedulePriceChange(
|
||||||
|
[FromRoute] long id,
|
||||||
|
[FromBody] SchedulePriceChangeRequest request)
|
||||||
|
{
|
||||||
|
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
|
||||||
|
|
||||||
|
var validation = await _scheduleValidator.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<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
|
||||||
|
/// Idempotent: calling on an already-inactive row is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("{id:long}/deactivate")]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> Deactivate([FromRoute] long id)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
|
||||||
|
new DeactivateChargeableCharConfigCommand(id));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
|
||||||
|
/// Guard rules (enforced by SP):
|
||||||
|
/// - ALREADY_ACTIVE: target row is already active → 409
|
||||||
|
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
|
||||||
|
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("{id:long}/reactivate")]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Reactivate([FromRoute] long id)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
|
||||||
|
new ReactivateChargeableCharConfigCommand(id));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a ChargeableCharConfig row.
|
||||||
|
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
|
||||||
|
/// The row disappears from all current-state queries.
|
||||||
|
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||||
|
/// Returns 200 + { id } consistent with the Deactivate pattern.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{id:long}")]
|
||||||
|
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete([FromRoute] long id)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
|
||||||
|
new DeleteChargeableCharConfigCommand(id));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
||||||
|
public sealed record CreateChargeableCharConfigRequest(
|
||||||
|
long? ProductTypeId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
|
|
||||||
|
/// <summary>PRC-001: Schedule price change request body.</summary>
|
||||||
|
public sealed record SchedulePriceChangeRequest(
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Products.Prices;
|
using SIGCM2.Application.Products.Prices;
|
||||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||||
@@ -11,7 +11,7 @@ namespace SIGCM2.Api.Controllers;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PRD-003: ProductPrices historic pricing management.
|
/// PRD-003: ProductPrices historic pricing management.
|
||||||
/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role).
|
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||||
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
@@ -31,19 +31,28 @@ public sealed class ProductPricesController : ControllerBase
|
|||||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
// ── READ endpoint ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the full price history for a Product, ordered descending by PriceValidFrom.
|
/// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom.
|
||||||
/// Returns 200 with empty array if the product has no prices yet.
|
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
|
||||||
|
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
|
||||||
/// Returns 404 if the product does not exist.
|
/// Returns 404 if the product does not exist.
|
||||||
|
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
[HttpGet("api/v1/products/{id:int}/prices")]
|
||||||
[Authorize]
|
[RequirePermission("catalogo:productos:gestionar")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
|
public async Task<IActionResult> GetProductPrices(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromQuery] int? page,
|
||||||
|
[FromQuery] int? pageSize)
|
||||||
{
|
{
|
||||||
var query = new GetProductPricesQuery(id);
|
var query = new GetProductPricesQuery(
|
||||||
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
|
ProductId: id,
|
||||||
|
Page: page ?? 1,
|
||||||
|
PageSize: pageSize ?? 20);
|
||||||
|
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Filters;
|
namespace SIGCM2.Api.Filters;
|
||||||
|
|
||||||
@@ -645,6 +646,94 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// PRC-001: WordCounter + ChargeableCharConfig exceptions
|
||||||
|
case EmojiDetectedException emojiEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "emoji_not_allowed",
|
||||||
|
code = "EMOJI_NOT_ALLOWED",
|
||||||
|
message = emojiEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WordCountValidationException wordEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "word_count_validation",
|
||||||
|
code = "WORD_COUNT_VALIDATION",
|
||||||
|
field = wordEx.Field,
|
||||||
|
reason = wordEx.Reason,
|
||||||
|
message = wordEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ChargeableCharConfigInvalidException configInvalidEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "chargeable_char_invalid",
|
||||||
|
code = "CHARGEABLE_CHAR_INVALID",
|
||||||
|
field = configInvalidEx.Field,
|
||||||
|
reason = configInvalidEx.Reason,
|
||||||
|
message = configInvalidEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "chargeable_char_forward_only",
|
||||||
|
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
|
||||||
|
productTypeId = forwardOnlyCharEx.ProductTypeId,
|
||||||
|
symbol = forwardOnlyCharEx.Symbol,
|
||||||
|
newValidFrom = forwardOnlyCharEx.NewValidFrom,
|
||||||
|
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
|
||||||
|
message = forwardOnlyCharEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "chargeable_char_reactivation_not_allowed",
|
||||||
|
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
|
||||||
|
id = reactivationEx.Id,
|
||||||
|
reason = reactivationEx.Reason,
|
||||||
|
message = reactivationEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case KeyNotFoundException keyNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "not_found",
|
||||||
|
message = keyNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
case ValidationException validationEx:
|
case ValidationException validationEx:
|
||||||
var errors = validationEx.Errors
|
var errors = validationEx.Errors
|
||||||
.GroupBy(e => e.PropertyName)
|
.GroupBy(e => e.PropertyName)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
||||||
|
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
||||||
|
///
|
||||||
|
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
||||||
|
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
|
||||||
|
///
|
||||||
|
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
|
||||||
|
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
|
||||||
|
/// The Application service applies the per-ProductType > global priority rule.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChargeableCharConfigRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
|
||||||
|
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
|
||||||
|
/// Returns the Id of the newly inserted row.
|
||||||
|
/// Throws:
|
||||||
|
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
|
||||||
|
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
|
||||||
|
/// </summary>
|
||||||
|
Task<long> InsertWithCloseAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
string symbol,
|
||||||
|
string category,
|
||||||
|
decimal price,
|
||||||
|
DateOnly validFrom,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
|
||||||
|
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
|
||||||
|
/// The SP returns both per-ProductType AND global rows — callers apply priority.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||||
|
long productTypeId,
|
||||||
|
DateOnly asOfDate,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns paginated rows filtered by ProductTypeId and IsActive.
|
||||||
|
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns total row count for the given filters (used for pagination metadata).
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the row with the given Id, or null if not found.
|
||||||
|
/// </summary>
|
||||||
|
Task<ChargeableCharConfig?> GetByIdAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
||||||
|
/// Idempotent: no-op if already inactive.
|
||||||
|
/// Called inside the ambient TransactionScope of the handler.
|
||||||
|
/// </summary>
|
||||||
|
Task DeactivateAsync(
|
||||||
|
long id,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
|
||||||
|
/// Guard rules (enforced by SP):
|
||||||
|
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||||
|
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||||
|
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||||
|
/// 50404 → row not found → ChargeableCharConfigInvalidException
|
||||||
|
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
|
||||||
|
/// </summary>
|
||||||
|
Task<ChargeableCharConfig> ReactivateAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
|
||||||
|
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
|
||||||
|
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
|
||||||
|
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||||
|
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||||
|
/// Throws KeyNotFoundException if the row does not exist.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
@@ -21,11 +22,14 @@ public interface IProductPriceRepository
|
|||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
|
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
|
||||||
/// Returns empty list when the product has no price history.
|
/// Caller is responsible for clamping page (≥ 1) and pageSize (1–100) before calling.
|
||||||
|
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
|
||||||
int productId,
|
int productId,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ using SIGCM2.Application.ProductTypes.Update;
|
|||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
using SIGCM2.Application.ProductTypes.List;
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
using SIGCM2.Application.ProductTypes.GetById;
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -188,7 +196,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// ProductPrices (PRD-003)
|
// ProductPrices (PRD-003)
|
||||||
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||||
services.AddScoped<IProductPricingService, ProductPricingService>();
|
services.AddScoped<IProductPricingService, ProductPricingService>();
|
||||||
|
|
||||||
// ProductTypes (PRD-001)
|
// ProductTypes (PRD-001)
|
||||||
@@ -200,6 +208,16 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||||
|
|
||||||
|
// ChargeableCharConfig (PRC-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
||||||
|
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChargeableCharConfigDto(
|
||||||
|
long Id,
|
||||||
|
long? ProductTypeId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom,
|
||||||
|
DateOnly? ValidTo,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Implements IChargeableCharConfigService.
|
||||||
|
/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
|
||||||
|
/// the per-ProductType > global priority rule in memory.
|
||||||
|
///
|
||||||
|
/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
|
||||||
|
/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||||
|
long productTypeId,
|
||||||
|
DateOnly asOf,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
|
||||||
|
|
||||||
|
// Build a dictionary keyed by Symbol.
|
||||||
|
// Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == null).
|
||||||
|
var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Two-pass: first add global rows, then overwrite with per-ProductType rows.
|
||||||
|
foreach (var row in allRows.Where(r => r.ProductTypeId is null))
|
||||||
|
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||||
|
|
||||||
|
foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
|
||||||
|
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
|
||||||
|
/// at the time of word counting. Used by IChargeableCharConfigService.
|
||||||
|
/// Keyed by Symbol in the returned dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChargeableCharSnapshot(
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to create a new ChargeableCharConfig.
|
||||||
|
/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateChargeableCharConfigCommand(
|
||||||
|
long? ProductTypeId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
|
||||||
|
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public CreateChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
long newId;
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
newId = await _repo.InsertWithCloseAsync(
|
||||||
|
command.ProductTypeId,
|
||||||
|
command.Symbol,
|
||||||
|
command.Category,
|
||||||
|
command.PricePerUnit,
|
||||||
|
command.ValidFrom);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.create",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
command.ProductTypeId,
|
||||||
|
command.Symbol,
|
||||||
|
command.Category,
|
||||||
|
command.PricePerUnit,
|
||||||
|
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateChargeableCharConfigResponse(
|
||||||
|
newId,
|
||||||
|
command.Symbol,
|
||||||
|
command.PricePerUnit,
|
||||||
|
command.ValidFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.WordCounter;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
|
||||||
|
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateChargeableCharConfigCommandValidator
|
||||||
|
: AbstractValidator<CreateChargeableCharConfigCommand>
|
||||||
|
{
|
||||||
|
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var today = timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
RuleFor(x => x.Symbol)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Symbol no puede estar vacío.")
|
||||||
|
.MaximumLength(4)
|
||||||
|
.WithMessage("Symbol no puede exceder 4 caracteres.")
|
||||||
|
.Must(s => !WordCounterService.ContainsEmoji(s))
|
||||||
|
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
|
||||||
|
|
||||||
|
RuleFor(x => x.Category)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Category no puede estar vacío.")
|
||||||
|
.Must(ChargeableCharCategories.IsValid)
|
||||||
|
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
||||||
|
|
||||||
|
RuleFor(x => x.PricePerUnit)
|
||||||
|
.GreaterThanOrEqualTo(0m)
|
||||||
|
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidFrom)
|
||||||
|
.GreaterThanOrEqualTo(today)
|
||||||
|
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateChargeableCharConfigResponse(
|
||||||
|
long Id,
|
||||||
|
string Symbol,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeactivateChargeableCharConfigCommand(long Id);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
|
||||||
|
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeactivateChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeactivateChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeactivateChargeableCharConfigResponse> Handle(
|
||||||
|
DeactivateChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
var today = _timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
// 1. Load existing — ensures the row exists.
|
||||||
|
var existing = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||||
|
|
||||||
|
// 2. TX + deactivate + audit (fail-closed).
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
await _repo.DeactivateAsync(command.Id, today);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.deactivate",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new
|
||||||
|
{
|
||||||
|
id = existing.Id,
|
||||||
|
symbol = existing.Symbol,
|
||||||
|
productTypeId = existing.ProductTypeId,
|
||||||
|
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
},
|
||||||
|
deactivatedOn = today.ToString("yyyy-MM-dd"),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeactivateChargeableCharConfigResponse(
|
||||||
|
Id: command.Id,
|
||||||
|
ValidTo: today);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
|
||||||
|
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeactivateChargeableCharConfigResponse(
|
||||||
|
long Id,
|
||||||
|
DateOnly ValidTo);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
|
||||||
|
/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
|
||||||
|
/// (SysEndTime = delete time). The row disappears from all current-state queries but
|
||||||
|
/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
|
||||||
|
/// to the FAC-001 followup issue.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteChargeableCharConfigCommand(long Id);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
|
||||||
|
/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
|
||||||
|
///
|
||||||
|
/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
|
||||||
|
/// SysEndTime = deletion timestamp. This means:
|
||||||
|
/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
|
||||||
|
/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
|
||||||
|
/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
|
||||||
|
///
|
||||||
|
/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeleteChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeleteChargeableCharConfigResponse> Handle(
|
||||||
|
DeleteChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load existing — ensures the row exists before opening TX.
|
||||||
|
var existing = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||||
|
|
||||||
|
// 2. TX + delete + audit (fail-closed).
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
await _repo.DeleteAsync(command.Id);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.delete",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new
|
||||||
|
{
|
||||||
|
id = existing.Id,
|
||||||
|
symbol = existing.Symbol,
|
||||||
|
productTypeId = existing.ProductTypeId,
|
||||||
|
isActive = existing.IsActive,
|
||||||
|
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
},
|
||||||
|
deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeleteChargeableCharConfigResponse(Id: command.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for a successful delete operation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteChargeableCharConfigResponse(long Id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
|
||||||
|
/// Returns null if not found (caller decides whether to 404).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetChargeableCharConfigByIdQuery(long Id);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
|
||||||
|
/// Returns null DTO when not found (API layer maps to 404).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetChargeableCharConfigByIdQueryHandler
|
||||||
|
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(query.Id);
|
||||||
|
return entity is null ? null : ToDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||||
|
c.Id,
|
||||||
|
c.ProductTypeId,
|
||||||
|
c.Symbol,
|
||||||
|
c.Category,
|
||||||
|
c.PricePerUnit,
|
||||||
|
c.ValidFrom,
|
||||||
|
c.ValidTo,
|
||||||
|
c.IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
|
||||||
|
///
|
||||||
|
/// Priority rule: per-ProductType row overrides global (ProductTypeId IS NULL) for the same Symbol.
|
||||||
|
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChargeableCharConfigService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the resolved active config for the given ProductType as of the given date.
|
||||||
|
/// Per-ProductType rows take priority over global rows for the same Symbol.
|
||||||
|
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
|
||||||
|
/// Returns an empty dictionary if no config exists at all.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||||
|
long productTypeId,
|
||||||
|
DateOnly asOf,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
|
||||||
|
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ListChargeableCharConfigQuery(
|
||||||
|
long? ProductTypeId,
|
||||||
|
bool ActiveOnly,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
|
||||||
|
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListChargeableCharConfigQueryHandler
|
||||||
|
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
|
||||||
|
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
var skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
|
||||||
|
var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
|
||||||
|
|
||||||
|
var dtos = items.Select(ToDto).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||||
|
c.Id,
|
||||||
|
c.ProductTypeId,
|
||||||
|
c.Symbol,
|
||||||
|
c.Category,
|
||||||
|
c.PricePerUnit,
|
||||||
|
c.ValidFrom,
|
||||||
|
c.ValidTo,
|
||||||
|
c.IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
|
||||||
|
/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ReactivateChargeableCharConfigCommand(long Id);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
|
||||||
|
/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
|
||||||
|
///
|
||||||
|
/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
|
||||||
|
/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
|
||||||
|
/// ExceptionFilter which maps them to HTTP 409.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReactivateChargeableCharConfigCommandHandler
|
||||||
|
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public ReactivateChargeableCharConfigCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReactivateChargeableCharConfigResponse> Handle(
|
||||||
|
ReactivateChargeableCharConfigCommand command)
|
||||||
|
{
|
||||||
|
// Open TX before calling SP so that audit failure rolls back the SP work.
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
// SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
|
||||||
|
// Returns the reactivated entity so we can populate the response and the audit log.
|
||||||
|
var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.reactivate",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
id = reactivated.Id,
|
||||||
|
symbol = reactivated.Symbol,
|
||||||
|
productTypeId = reactivated.ProductTypeId,
|
||||||
|
validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ReactivateChargeableCharConfigResponse(
|
||||||
|
Id: reactivated.Id,
|
||||||
|
ProductTypeId: reactivated.ProductTypeId,
|
||||||
|
Symbol: reactivated.Symbol,
|
||||||
|
Category: reactivated.Category,
|
||||||
|
PricePerUnit: reactivated.PricePerUnit,
|
||||||
|
ValidFrom: reactivated.ValidFrom,
|
||||||
|
IsActive: reactivated.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for a successful reactivation.
|
||||||
|
/// Returns the current state of the row after it has been re-opened.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ReactivateChargeableCharConfigResponse(
|
||||||
|
long Id,
|
||||||
|
long? ProductTypeId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
|
||||||
|
/// Id: the existing row whose price should be superseded.
|
||||||
|
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SchedulePriceChangeCommand(
|
||||||
|
long Id,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateOnly ValidFrom);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Handler for SchedulePriceChangeCommand.
|
||||||
|
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulePriceChangeCommandHandler
|
||||||
|
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
|
||||||
|
{
|
||||||
|
private readonly IChargeableCharConfigRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public SchedulePriceChangeCommandHandler(
|
||||||
|
IChargeableCharConfigRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
|
||||||
|
var existing = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||||
|
|
||||||
|
// 2. Domain entity validates forward-only rule and builds the new entity value.
|
||||||
|
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
|
||||||
|
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
|
||||||
|
|
||||||
|
// 3. TX + SP + audit (fail-closed).
|
||||||
|
long newId;
|
||||||
|
using (var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled))
|
||||||
|
{
|
||||||
|
newId = await _repo.InsertWithCloseAsync(
|
||||||
|
newEntity.ProductTypeId,
|
||||||
|
newEntity.Symbol,
|
||||||
|
newEntity.Category,
|
||||||
|
newEntity.PricePerUnit,
|
||||||
|
newEntity.ValidFrom);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "tasacion.chargeable_char.price_change",
|
||||||
|
targetType: "ChargeableCharConfig",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new
|
||||||
|
{
|
||||||
|
id = existing.Id,
|
||||||
|
pricePerUnit = existing.PricePerUnit,
|
||||||
|
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
},
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
pricePerUnit = newEntity.PricePerUnit,
|
||||||
|
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchedulePriceChangeResponse(
|
||||||
|
NewId: newId,
|
||||||
|
PreviousValidFrom: existing.ValidFrom,
|
||||||
|
NewValidFrom: newEntity.ValidFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
|
||||||
|
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
|
||||||
|
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
|
||||||
|
/// where the existing entity is loaded.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
|
||||||
|
{
|
||||||
|
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var today = timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0L)
|
||||||
|
.WithMessage("Id debe ser un entero positivo.");
|
||||||
|
|
||||||
|
RuleFor(x => x.PricePerUnit)
|
||||||
|
.GreaterThan(0m)
|
||||||
|
.WithMessage("PricePerUnit debe ser > 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidFrom)
|
||||||
|
.GreaterThanOrEqualTo(today)
|
||||||
|
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for SchedulePriceChangeCommand.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SchedulePriceChangeResponse(
|
||||||
|
long NewId,
|
||||||
|
DateOnly PreviousValidFrom,
|
||||||
|
DateOnly NewValidFrom);
|
||||||
@@ -75,7 +75,10 @@ public sealed class AddProductPriceCommandHandler
|
|||||||
} // TX disposed (committed) here — BEFORE the post-commit read below.
|
} // TX disposed (committed) here — BEFORE the post-commit read below.
|
||||||
|
|
||||||
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
||||||
var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);
|
// La primera página (pageSize=2) es suficiente: solo necesitamos el nuevo y el cerrado,
|
||||||
|
// que son siempre los más recientes (ORDER BY PriceValidFrom DESC).
|
||||||
|
var pricesPage = await _pricesRepo.GetByProductIdAsync(command.ProductId, page: 1, pageSize: 2);
|
||||||
|
var prices = pricesPage.Items;
|
||||||
var created = prices.Single(p => p.Id == newId);
|
var created = prices.Single(p => p.Id == newId);
|
||||||
var closed = closedId.HasValue
|
var closed = closedId.HasValue
|
||||||
? prices.SingleOrDefault(p => p.Id == closedId.Value)
|
? prices.SingleOrDefault(p => p.Id == closedId.Value)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PRD-003 — Query para obtener el historial de precios de un Product.
|
/// PRD-003 (paginated) — Query para obtener el historial de precios de un Product.
|
||||||
/// Devuelve lista ordenada descending por PriceValidFrom (activo primero).
|
/// Devuelve PagedResult ordenado descending por PriceValidFrom (activo primero).
|
||||||
/// Lanza ProductNotFoundException si el producto no existe.
|
/// Lanza ProductNotFoundException si el producto no existe.
|
||||||
|
/// Page y PageSize son clampeados por el handler: page ≥ 1, pageSize ∈ [1, 100].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record GetProductPricesQuery(int ProductId);
|
public sealed record GetProductPricesQuery(
|
||||||
|
int ProductId,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20);
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Products.Prices;
|
using SIGCM2.Application.Products.Prices;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PRD-003 — Handler de GetProductPricesQuery.
|
/// PRD-003 (paginated) — Handler de GetProductPricesQuery.
|
||||||
/// Verifica que el producto exista (404 si no), luego retorna historial de precios
|
/// Verifica que el producto exista (404 si no), aplica clamping defensivo de
|
||||||
/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY).
|
/// page/pageSize y retorna PagedResult ordenado descending por PriceValidFrom.
|
||||||
/// Lista vacía es válida (nuevo producto sin precios registrados aún).
|
/// Lista vacía es válida (nuevo producto sin precios o página más allá del total).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetProductPricesQueryHandler
|
public sealed class GetProductPricesQueryHandler
|
||||||
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
|
: ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>
|
||||||
{
|
{
|
||||||
private readonly IProductPriceRepository _pricesRepo;
|
private readonly IProductPriceRepository _pricesRepo;
|
||||||
private readonly IProductRepository _productsRepo;
|
private readonly IProductRepository _productsRepo;
|
||||||
@@ -25,18 +26,24 @@ public sealed class GetProductPricesQueryHandler
|
|||||||
_productsRepo = productsRepo;
|
_productsRepo = productsRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ProductPriceDto>> Handle(GetProductPricesQuery query)
|
public async Task<PagedResult<ProductPriceDto>> Handle(GetProductPricesQuery query)
|
||||||
{
|
{
|
||||||
// Verifica existencia del producto (lanza 404 si no existe).
|
// Verifica existencia del producto (lanza 404 si no existe).
|
||||||
_ = await _productsRepo.GetByIdAsync(query.ProductId)
|
_ = await _productsRepo.GetByIdAsync(query.ProductId)
|
||||||
?? throw new ProductNotFoundException(query.ProductId);
|
?? throw new ProductNotFoundException(query.ProductId);
|
||||||
|
|
||||||
var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId);
|
// Clamping defensivo — igual al patrón de ListProductsQueryHandler.
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
|
||||||
return prices
|
var paged = await _pricesRepo.GetByProductIdAsync(query.ProductId, page, pageSize);
|
||||||
|
|
||||||
|
var dtoItems = paged.Items
|
||||||
.Select(p => new ProductPriceDto(
|
.Select(p => new ProductPriceDto(
|
||||||
p.Id, p.ProductId, p.Price,
|
p.Id, p.ProductId, p.Price,
|
||||||
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
|
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ProductPriceDto>(dtoItems, paged.Page, paged.PageSize, paged.Total);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Canonical category names for chargeable characters.
|
||||||
|
/// Persisted as nvarchar(32) in the database (enum-as-string).
|
||||||
|
/// </summary>
|
||||||
|
public static class ChargeableCharCategories
|
||||||
|
{
|
||||||
|
public const string Currency = "Currency";
|
||||||
|
public const string Percentage = "Percentage";
|
||||||
|
public const string Exclamation = "Exclamation";
|
||||||
|
public const string Question = "Question";
|
||||||
|
public const string Other = "Other";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> Valid = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
Currency, Percentage, Exclamation, Question, Other
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Returns true if the given category string is a known valid category.</summary>
|
||||||
|
public static bool IsValid(string? category) => category != null && Valid.Contains(category);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Rich domain entity for chargeable character configuration.
|
||||||
|
/// Represents a price-per-occurrence for a special character in classified ad text,
|
||||||
|
/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null).
|
||||||
|
///
|
||||||
|
/// Forward-only price history: each new price schedules a NEW row; the current row
|
||||||
|
/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
|
||||||
|
/// this instance — it returns a new one. The actual close+insert happens in the repository.
|
||||||
|
///
|
||||||
|
/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfig
|
||||||
|
{
|
||||||
|
public long Id { get; }
|
||||||
|
public int? ProductTypeId { get; }
|
||||||
|
public string Symbol { get; }
|
||||||
|
public string Category { get; }
|
||||||
|
public decimal PricePerUnit { get; private set; }
|
||||||
|
public DateOnly ValidFrom { get; }
|
||||||
|
public DateOnly? ValidTo { get; private set; }
|
||||||
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
|
private ChargeableCharConfig(
|
||||||
|
long id, int? productTypeId, string symbol, string category,
|
||||||
|
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
ProductTypeId = productTypeId;
|
||||||
|
Symbol = symbol;
|
||||||
|
Category = category;
|
||||||
|
PricePerUnit = price;
|
||||||
|
ValidFrom = validFrom;
|
||||||
|
ValidTo = validTo;
|
||||||
|
IsActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for new configs. Enforces all domain invariants.
|
||||||
|
/// Id is set to 0 until the entity is persisted.
|
||||||
|
/// </summary>
|
||||||
|
public static ChargeableCharConfig Create(
|
||||||
|
int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(symbol))
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(Symbol), "Symbol no puede estar vacío.");
|
||||||
|
|
||||||
|
if (symbol.Length > 4)
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(Symbol), "Symbol no puede exceder 4 caracteres.");
|
||||||
|
|
||||||
|
if (price <= 0m)
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(PricePerUnit), "PricePerUnit debe ser > 0.");
|
||||||
|
|
||||||
|
if (!ChargeableCharCategories.IsValid(category))
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
|
||||||
|
|
||||||
|
return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructor from database (no validation). Used by the repository mapper only.
|
||||||
|
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
|
||||||
|
/// </summary>
|
||||||
|
public static ChargeableCharConfig Rehydrate(
|
||||||
|
long id, int? productTypeId, string symbol, string category,
|
||||||
|
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
|
||||||
|
=> new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules a new price (forward-only semantics).
|
||||||
|
/// Returns a NEW ChargeableCharConfig instance with the updated price and validFrom.
|
||||||
|
/// This instance is NOT mutated — the close+insert of rows happens in the repository via SP.
|
||||||
|
///
|
||||||
|
/// Validates:
|
||||||
|
/// - newValidFrom >= today (Argentina) via TimeProvider
|
||||||
|
/// - newValidFrom > current ValidFrom (strictly greater — forward-only)
|
||||||
|
/// - newPrice > 0
|
||||||
|
/// </summary>
|
||||||
|
public ChargeableCharConfig ScheduleNewPrice(decimal newPrice, DateOnly newValidFrom, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var today = timeProvider.GetArgentinaToday();
|
||||||
|
|
||||||
|
if (newValidFrom < today)
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(ValidFrom),
|
||||||
|
$"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
|
||||||
|
|
||||||
|
if (newValidFrom <= ValidFrom)
|
||||||
|
throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom);
|
||||||
|
|
||||||
|
// Create validates price > 0 and category — reuse factory
|
||||||
|
return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates this config row. Sets IsActive = false and ValidTo = today.
|
||||||
|
/// Idempotent: no-op if already inactive.
|
||||||
|
/// </summary>
|
||||||
|
public void Deactivate(DateOnly today)
|
||||||
|
{
|
||||||
|
if (!IsActive) return; // idempotent
|
||||||
|
IsActive = false;
|
||||||
|
ValidTo = today;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Thrown when attempting to schedule a new price with a ValidFrom that is
|
||||||
|
/// not strictly greater than the currently active row's ValidFrom. → HTTP 409
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigForwardOnlyException : DomainException
|
||||||
|
{
|
||||||
|
public int? ProductTypeId { get; }
|
||||||
|
public string Symbol { get; }
|
||||||
|
public DateOnly NewValidFrom { get; }
|
||||||
|
public DateOnly ActiveValidFrom { get; }
|
||||||
|
|
||||||
|
public ChargeableCharConfigForwardOnlyException(
|
||||||
|
int? productTypeId,
|
||||||
|
string symbol,
|
||||||
|
DateOnly newValidFrom,
|
||||||
|
DateOnly activeValidFrom)
|
||||||
|
: base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).")
|
||||||
|
{
|
||||||
|
ProductTypeId = productTypeId;
|
||||||
|
Symbol = symbol;
|
||||||
|
NewValidFrom = newValidFrom;
|
||||||
|
ActiveValidFrom = activeValidFrom;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Thrown when a ChargeableCharConfig value fails domain business validation
|
||||||
|
/// (e.g., PricePerUnit ≤ 0, Symbol empty/too long, Category unknown, ValidFrom in the past).
|
||||||
|
/// → HTTP 400
|
||||||
|
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigInvalidException : DomainException
|
||||||
|
{
|
||||||
|
public string Field { get; }
|
||||||
|
public string Reason { get; }
|
||||||
|
|
||||||
|
public ChargeableCharConfigInvalidException(string field, string reason)
|
||||||
|
: base($"Valor inválido para {field}: {reason}")
|
||||||
|
{
|
||||||
|
Field = field;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Thrown when a reactivation attempt is blocked by a guard rule.
|
||||||
|
/// Maps to HTTP 409.
|
||||||
|
///
|
||||||
|
/// Reason codes:
|
||||||
|
/// ALREADY_ACTIVE — target row is currently active (50410)
|
||||||
|
/// VIGENTE_EXISTS — a different active row exists for (ProductTypeId, Symbol) (50411)
|
||||||
|
/// POSTERIOR_ROWS_EXIST — rows with higher ValidFrom exist after the target row (50412)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
|
||||||
|
{
|
||||||
|
public long Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; }
|
||||||
|
|
||||||
|
public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
|
||||||
|
: base($"Reactivation not allowed for config {id}: {reason}")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Thrown when the input text contains any Unicode emoji codepoint.
|
||||||
|
/// Emoji detection occurs BEFORE normalization. → HTTP 400
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EmojiDetectedException : DomainException
|
||||||
|
{
|
||||||
|
/// <summary>The Unicode codepoint value of the first detected emoji rune.</summary>
|
||||||
|
public int DetectedCodepoint { get; }
|
||||||
|
|
||||||
|
public EmojiDetectedException(int detectedCodepoint)
|
||||||
|
: base($"El texto contiene emojis (U+{detectedCodepoint:X4}), que no son tarifables. Eliminálos antes de continuar.")
|
||||||
|
{
|
||||||
|
DetectedCodepoint = detectedCodepoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Thrown when WordCounterService input fails validation
|
||||||
|
/// (e.g., exceeds maximum length of 2000 chars). → HTTP 400
|
||||||
|
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WordCountValidationException : DomainException
|
||||||
|
{
|
||||||
|
public string Field { get; }
|
||||||
|
public string Reason { get; }
|
||||||
|
|
||||||
|
public WordCountValidationException(string field, string reason)
|
||||||
|
: base($"Valor inválido para {field}: {reason}")
|
||||||
|
{
|
||||||
|
Field = field;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
Normal file
34
src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace SIGCM2.Domain.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain-layer extension for TimeProvider: returns Argentina civil date.
|
||||||
|
/// Mirrors SIGCM2.Application.Common.TimeProviderArgentinaExtensions
|
||||||
|
/// but lives in Domain to avoid a Domain → Application dependency.
|
||||||
|
/// Domain layer is pure — no Application references allowed.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DomainTimeProviderExtensions
|
||||||
|
{
|
||||||
|
private const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires";
|
||||||
|
private const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time";
|
||||||
|
|
||||||
|
private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz();
|
||||||
|
|
||||||
|
internal static DateOnly GetArgentinaToday(this TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var utcNow = timeProvider.GetUtcNow();
|
||||||
|
var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz);
|
||||||
|
return DateOnly.FromDateTime(argentinaNow.DateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo LoadArgentinaTz()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
Normal file
10
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Domain.Pricing.WordCounter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Immutable value object representing the result of a word count operation.
|
||||||
|
/// TotalWords: number of whitespace-separated tokens after normalization.
|
||||||
|
/// SpecialCharCounts: map of category name → occurrence count in the ORIGINAL (pre-normalization) text.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WordCountResult(
|
||||||
|
int TotalWords,
|
||||||
|
IReadOnlyDictionary<string, int> SpecialCharCounts);
|
||||||
144
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
Normal file
144
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Pricing.WordCounter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Pure domain service for counting words in classified ad text.
|
||||||
|
///
|
||||||
|
/// Algorithm (in order):
|
||||||
|
/// 1. Null/empty → WordCountResult(0, empty)
|
||||||
|
/// 2. Length check: rawText.Length > 2000 → WordCountValidationException
|
||||||
|
/// 3. Emoji detection via Rune.EnumerateRunes + IsEmojiRune → EmojiDetectedException
|
||||||
|
/// 4. Count special chars by category (BEFORE replacement — anti-fraud ordering)
|
||||||
|
/// 5. Replace specials with space; normalize line breaks; collapse whitespace; trim
|
||||||
|
/// 6. Split(' ', RemoveEmptyEntries) → token count
|
||||||
|
/// 7. Return WordCountResult
|
||||||
|
///
|
||||||
|
/// Tildes (á é í ó ú ñ etc.) are regular word letters — NOT specials.
|
||||||
|
/// Hyphens are NOT specials — they split words naturally via whitespace split only when
|
||||||
|
/// they appear as separators between non-whitespace chars (e.g. "buen-estado" → the hyphen
|
||||||
|
/// itself becomes a word boundary because Split splits on spaces only, so hyphenated words
|
||||||
|
/// are NOT split by default). Wait — spec GC-18: "buen-estado casi-nuevo" → TotalWords=4.
|
||||||
|
/// This means hyphen DOES split. The tokenizer must split on hyphen too.
|
||||||
|
///
|
||||||
|
/// Design resolution: after normalization, split on whitespace OR hyphen.
|
||||||
|
/// Hyphens are treated as word boundaries (split token), not as specials counted in SpecialCharCounts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WordCounterService
|
||||||
|
{
|
||||||
|
public const int MaxInputLength = 2000;
|
||||||
|
|
||||||
|
// Category patterns — order matters for counting (BEFORE replacement)
|
||||||
|
private static readonly (string Category, Regex Pattern)[] CategoryPatterns =
|
||||||
|
[
|
||||||
|
("Currency", new Regex(@"[\$€¥£]", RegexOptions.Compiled)),
|
||||||
|
("Percentage", new Regex(@"%", RegexOptions.Compiled)),
|
||||||
|
("Exclamation", new Regex(@"[!¡]", RegexOptions.Compiled)),
|
||||||
|
("Question", new Regex(@"[?¿]", RegexOptions.Compiled)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Collapses any run of spaces/tabs into a single space
|
||||||
|
private static readonly Regex WhitespaceCollapseRegex = new(@"[ \t]+", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public WordCountResult Count(string? rawText)
|
||||||
|
{
|
||||||
|
// Step 1: null/empty fast path
|
||||||
|
if (string.IsNullOrEmpty(rawText))
|
||||||
|
return new WordCountResult(0, new Dictionary<string, int>());
|
||||||
|
|
||||||
|
// Step 2: length check (on raw input, pre-normalization)
|
||||||
|
if (rawText.Length > MaxInputLength)
|
||||||
|
throw new WordCountValidationException(
|
||||||
|
nameof(rawText),
|
||||||
|
$"El texto supera el máximo de {MaxInputLength} caracteres.");
|
||||||
|
|
||||||
|
// Step 3: emoji detection — fail-fast on first emoji rune found
|
||||||
|
foreach (var rune in rawText.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
throw new EmojiDetectedException(rune.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: count specials by category on ORIGINAL text (anti-fraud ordering)
|
||||||
|
var counts = new Dictionary<string, int>();
|
||||||
|
foreach (var (category, pattern) in CategoryPatterns)
|
||||||
|
{
|
||||||
|
var matchCount = pattern.Matches(rawText).Count;
|
||||||
|
if (matchCount > 0)
|
||||||
|
counts[category] = matchCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: normalize
|
||||||
|
// 5a. Replace specials with space (each occurrence → space, enabling anti-fraud split)
|
||||||
|
var normalized = rawText;
|
||||||
|
foreach (var (_, pattern) in CategoryPatterns)
|
||||||
|
normalized = pattern.Replace(normalized, " ");
|
||||||
|
|
||||||
|
// 5b. Normalize line endings to space
|
||||||
|
normalized = normalized.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||||
|
|
||||||
|
// 5c. Replace hyphens with space (GC-18: "buen-estado" → 2 tokens)
|
||||||
|
// Hyphens are word-boundary separators, not special counted chars.
|
||||||
|
normalized = normalized.Replace('-', ' ');
|
||||||
|
|
||||||
|
// 5d. Collapse consecutive spaces/tabs to single space, then trim
|
||||||
|
normalized = WhitespaceCollapseRegex.Replace(normalized, " ").Trim();
|
||||||
|
|
||||||
|
// Step 6: tokenize on space
|
||||||
|
if (string.IsNullOrEmpty(normalized))
|
||||||
|
return new WordCountResult(0, counts);
|
||||||
|
|
||||||
|
var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Step 7: return result
|
||||||
|
return new WordCountResult(tokens.Length, counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the given string contains any emoji codepoint.
|
||||||
|
/// Used by validators that must reject emojis in user-facing identifiers
|
||||||
|
/// (e.g. ChargeableCharConfig.Symbol) where the frontend blocker can be bypassed
|
||||||
|
/// by direct API calls. Shares the same IsEmojiRune Unicode ranges used by Count().
|
||||||
|
/// </summary>
|
||||||
|
public static bool ContainsEmoji(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the given rune is an emoji codepoint.
|
||||||
|
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsEmojiRune(Rune r)
|
||||||
|
{
|
||||||
|
int v = r.Value;
|
||||||
|
|
||||||
|
// Main emoji blocks
|
||||||
|
if (v >= 0x1F300 && v <= 0x1F5FF) return true; // Misc Symbols & Pictographs
|
||||||
|
if (v >= 0x1F600 && v <= 0x1F64F) return true; // Emoticons
|
||||||
|
if (v >= 0x1F680 && v <= 0x1F6FF) return true; // Transport & Map
|
||||||
|
if (v >= 0x1F700 && v <= 0x1F77F) return true; // Alchemical Symbols
|
||||||
|
if (v >= 0x1F900 && v <= 0x1F9FF) return true; // Supplemental Symbols & Pictographs
|
||||||
|
if (v >= 0x1FA00 && v <= 0x1FAFF) return true; // Symbols and Pictographs Extended-A
|
||||||
|
|
||||||
|
// Misc Symbols and Dingbats (conditional — many non-emoji chars here too,
|
||||||
|
// but the design includes these ranges)
|
||||||
|
if (v >= 0x2600 && v <= 0x26FF) return true; // Misc Symbols (⚡☀️etc.)
|
||||||
|
if (v >= 0x2700 && v <= 0x27BF) return true; // Dingbats (✂️✈️etc.)
|
||||||
|
|
||||||
|
// Variation Selector-16 (U+FE0F) — used to force emoji presentation
|
||||||
|
if (v == 0xFE0F) return true;
|
||||||
|
|
||||||
|
// Zero Width Joiner (U+200D) — used in compound emoji (👨👩👧, 🧑🤝🧑)
|
||||||
|
if (v == 0x200D) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
||||||
// PRD-003: ProductPrices históricos
|
// PRD-003: ProductPrices históricos
|
||||||
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
|
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
|
||||||
|
// PRC-001: ChargeableCharConfig — caracteres especiales tasables
|
||||||
|
services.AddScoped<IChargeableCharConfigRepository, ChargeableCharConfigRepository>();
|
||||||
|
|
||||||
// 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,340 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
|
||||||
|
///
|
||||||
|
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
|
||||||
|
/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
|
||||||
|
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
|
||||||
|
///
|
||||||
|
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
|
||||||
|
/// Returns all rows (global + per-ProductType) — the Application service applies priority.
|
||||||
|
///
|
||||||
|
/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps:
|
||||||
|
/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||||
|
/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||||
|
/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||||
|
/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found)
|
||||||
|
///
|
||||||
|
/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException.
|
||||||
|
/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current
|
||||||
|
/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the
|
||||||
|
/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved.
|
||||||
|
///
|
||||||
|
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
|
||||||
|
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
|
||||||
|
///
|
||||||
|
/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
|
||||||
|
public ChargeableCharConfigRepository(SqlConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<long> InsertWithCloseAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
string symbol,
|
||||||
|
string category,
|
||||||
|
decimal price,
|
||||||
|
DateOnly validFrom,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
|
||||||
|
p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
|
||||||
|
p.Add("@Symbol", symbol, DbType.String, size: 4);
|
||||||
|
p.Add("@Category", category, DbType.String, size: 32);
|
||||||
|
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
|
||||||
|
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||||
|
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_ChargeableCharConfig_InsertWithClose",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50404)
|
||||||
|
{
|
||||||
|
// ProductType not found (SP validates ProductTypeId when not null)
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(productTypeId),
|
||||||
|
$"ProductType with Id={productTypeId} not found.");
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50409)
|
||||||
|
{
|
||||||
|
// Forward-only violation: new ValidFrom <= active.ValidFrom
|
||||||
|
throw new ChargeableCharConfigForwardOnlyException(
|
||||||
|
productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
|
||||||
|
symbol,
|
||||||
|
validFrom,
|
||||||
|
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Get<long>("@NewId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||||
|
long productTypeId,
|
||||||
|
DateOnly asOfDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
// SP @ProductTypeId is INT
|
||||||
|
p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
|
||||||
|
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_ChargeableCharConfig_GetActiveForProductType",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NULL-aware ProductTypeId filter:
|
||||||
|
// - productTypeId provided → filter to that ProductType only
|
||||||
|
// - productTypeId null → return all rows regardless of ProductType
|
||||||
|
// activeOnly filters by IsActive = 1.
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
|
||||||
|
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||||
|
ORDER BY ValidFrom DESC, Id DESC
|
||||||
|
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
|
||||||
|
ActiveOnly = activeOnly ? 1 : 0,
|
||||||
|
Skip = skip,
|
||||||
|
Take = take
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> CountAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
|
||||||
|
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
|
||||||
|
ActiveOnly = activeOnly ? 1 : 0
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<ChargeableCharConfig?> GetByIdAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task DeactivateAsync(
|
||||||
|
long id,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive.
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET IsActive = 0,
|
||||||
|
ValidTo = @Today
|
||||||
|
WHERE Id = @Id
|
||||||
|
AND IsActive = 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Today = today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<ChargeableCharConfig> ReactivateAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// IMPORTANT: the SP invocation and the subsequent SELECT MUST run on the SAME connection.
|
||||||
|
// Opening a second connection within the ambient TransactionScope would promote the
|
||||||
|
// transaction to DTC (distributed) — and MSDTC is typically not enabled on dev/prod
|
||||||
|
// servers. That promotion surfaces as an opaque 500 at the API boundary. Keeping both
|
||||||
|
// commands on a single SqlConnection keeps the tx as a local LTM (lightweight transaction).
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
p.Add("@Id", id, DbType.Int64);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_ChargeableCharConfig_ReactivateWithGuard",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50404)
|
||||||
|
{
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(id), $"ChargeableCharConfig with Id={id} not found.");
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50410)
|
||||||
|
{
|
||||||
|
throw new ChargeableCharConfigReactivationNotAllowedException(id, "ALREADY_ACTIVE");
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50411)
|
||||||
|
{
|
||||||
|
throw new ChargeableCharConfigReactivationNotAllowedException(id, "VIGENTE_EXISTS");
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50412)
|
||||||
|
{
|
||||||
|
throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the reactivated row on the SAME connection (avoids DTC promotion).
|
||||||
|
const string selectSql = """
|
||||||
|
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(selectSql, new { Id = id }, cancellationToken: ct));
|
||||||
|
|
||||||
|
return row is null
|
||||||
|
? throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).")
|
||||||
|
: MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task DeleteAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NOTE: With SYSTEM_VERSIONING ON on dbo.ChargeableCharConfig, this DELETE moves
|
||||||
|
// the row to dbo.ChargeableCharConfig_History (SysEndTime = deletion timestamp).
|
||||||
|
// The row disappears from current-state queries but is still queryable via
|
||||||
|
// FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||||
|
// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
||||||
|
const string sql = "DELETE FROM dbo.ChargeableCharConfig WHERE Id = @Id";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rowsAffected = await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
|
||||||
|
if (rowsAffected == 0)
|
||||||
|
throw new KeyNotFoundException($"ChargeableCharConfig with Id={id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row mapper ────────────────────────────────────────────────────────────
|
||||||
|
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
|
||||||
|
// Same pattern as ProductPriceRepository.
|
||||||
|
|
||||||
|
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
|
||||||
|
=> ChargeableCharConfig.Rehydrate(
|
||||||
|
id: r.Id,
|
||||||
|
productTypeId: r.ProductTypeId,
|
||||||
|
symbol: r.Symbol,
|
||||||
|
category: r.Category,
|
||||||
|
price: r.PricePerUnit,
|
||||||
|
validFrom: DateOnly.FromDateTime(r.ValidFrom),
|
||||||
|
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
|
||||||
|
isActive: r.IsActive);
|
||||||
|
|
||||||
|
private sealed record ChargeableCharConfigRow(
|
||||||
|
long Id,
|
||||||
|
int? ProductTypeId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateTime ValidFrom,
|
||||||
|
DateTime? ValidTo,
|
||||||
|
bool IsActive);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Data;
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -70,25 +71,42 @@ public sealed class ProductPriceRepository : IProductPriceRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
public async Task<PagedResult<ProductPrice>> GetByProductIdAsync(
|
||||||
int productId,
|
int productId,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC).
|
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC).
|
||||||
const string sql = """
|
// Two separate queries on the same open connection: COUNT first, then paginated DATA.
|
||||||
|
const string countSql = """
|
||||||
|
SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string dataSql = """
|
||||||
SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
|
SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
|
||||||
FROM dbo.ProductPrices
|
FROM dbo.ProductPrices
|
||||||
WHERE ProductId = @ProductId
|
WHERE ProductId = @ProductId
|
||||||
ORDER BY PriceValidFrom DESC, Id DESC
|
ORDER BY PriceValidFrom DESC, Id DESC
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
await using var connection = _factory.CreateConnection();
|
await using var connection = _factory.CreateConnection();
|
||||||
await connection.OpenAsync(ct);
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
var rows = await connection.QueryAsync<ProductPriceRow>(
|
var total = await connection.ExecuteScalarAsync<int>(
|
||||||
new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct));
|
new CommandDefinition(countSql, new { ProductId = productId }, cancellationToken: ct));
|
||||||
|
|
||||||
return rows.Select(MapRow).ToList();
|
var rows = await connection.QueryAsync<ProductPriceRow>(
|
||||||
|
new CommandDefinition(dataSql,
|
||||||
|
new { ProductId = productId, Offset = offset, PageSize = pageSize },
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
var items = rows.Select(MapRow).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ProductPrice>(items, page, pageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
88
src/web/package-lock.json
generated
88
src/web/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@@ -2264,6 +2265,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-id": {
|
"node_modules/@radix-ui/react-id": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ShoppingCart,
|
|
||||||
Calculator,
|
|
||||||
Zap,
|
|
||||||
Settings,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
Users,
|
||||||
|
UserPlus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
FileClock,
|
FileClock,
|
||||||
@@ -18,31 +14,50 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Layers,
|
Layers,
|
||||||
Package,
|
Package,
|
||||||
|
Hash,
|
||||||
|
Building2,
|
||||||
|
Calculator,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { useSidebarSections } from '@/hooks/useSidebarSections'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
disabled?: boolean
|
|
||||||
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
||||||
requiredPermission?: string
|
requiredPermission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
interface NavSection {
|
||||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
label: string
|
||||||
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
/** Icon de grupo, usado en el modo colapsado como trigger del fly-out. */
|
||||||
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
icon: React.ElementType
|
||||||
{ label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true },
|
/** Si true, la sección solo se muestra si el user tiene rol admin. */
|
||||||
{ label: 'Administración', href: '/administracion', icon: Settings, disabled: true },
|
adminOnly?: boolean
|
||||||
]
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
const adminItems: NavItem[] = [
|
// Item principal — siempre visible para usuarios autenticados
|
||||||
|
const dashboardItem: NavItem = {
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secciones agrupadas por dominio (Seguridad → Maestros → Catálogo → Tasación).
|
||||||
|
// Cada sección se oculta si todos sus items están filtrados por permisos.
|
||||||
|
const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
label: 'Seguridad',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
||||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||||
@@ -53,6 +68,13 @@ const adminItems: NavItem[] = [
|
|||||||
icon: FileClock,
|
icon: FileClock,
|
||||||
requiredPermission: 'administracion:auditoria:ver',
|
requiredPermission: 'administracion:auditoria:ver',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Maestros',
|
||||||
|
icon: Building2,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Medios',
|
label: 'Medios',
|
||||||
href: '/admin/medios',
|
href: '/admin/medios',
|
||||||
@@ -71,6 +93,13 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Store,
|
icon: Store,
|
||||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Catálogo',
|
||||||
|
icon: Package,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Rubros',
|
label: 'Rubros',
|
||||||
href: '/admin/rubros',
|
href: '/admin/rubros',
|
||||||
@@ -89,6 +118,21 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
requiredPermission: 'catalogo:productos:gestionar',
|
requiredPermission: 'catalogo:productos:gestionar',
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tasación',
|
||||||
|
icon: Calculator,
|
||||||
|
adminOnly: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Caracteres Tasables',
|
||||||
|
href: '/admin/tasacion/chargeable-chars',
|
||||||
|
icon: Hash,
|
||||||
|
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
@@ -100,11 +144,11 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const isAdmin = user?.rol === 'admin'
|
const isAdmin = user?.rol === 'admin'
|
||||||
const { collapsed: persisted, toggle } = useSidebar()
|
const { collapsed: persisted, toggle: toggleSidebar } = useSidebar()
|
||||||
const collapsed = forceExpanded ? false : persisted
|
const collapsed = forceExpanded ? false : persisted
|
||||||
|
const { isCollapsed: isSectionCollapsed, toggle: toggleSection } = useSidebarSections()
|
||||||
|
|
||||||
function isItemActive(item: NavItem): boolean {
|
function isItemActive(item: NavItem): boolean {
|
||||||
if (item.disabled) return false
|
|
||||||
if (item.href === '/') return pathname === '/'
|
if (item.href === '/') return pathname === '/'
|
||||||
if (item.href === '/usuarios') {
|
if (item.href === '/usuarios') {
|
||||||
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
||||||
@@ -113,6 +157,24 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
return pathname.startsWith(item.href)
|
return pathname.startsWith(item.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAccess(item: NavItem): boolean {
|
||||||
|
return !item.requiredPermission || (user?.permisos.includes(item.requiredPermission) ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if any item in the section matches the active route. */
|
||||||
|
function sectionContainsActive(section: NavSection): boolean {
|
||||||
|
return section.items.some(isItemActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter sections + items by role + permissions. Empty sections are hidden.
|
||||||
|
const visibleSections = navSections
|
||||||
|
.filter((section) => !section.adminOnly || isAdmin)
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter(hasAccess),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.items.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -121,7 +183,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
)}
|
)}
|
||||||
data-collapsed={collapsed}
|
data-collapsed={collapsed}
|
||||||
>
|
>
|
||||||
{/* Brand + Toggle (top header) */}
|
{/* Brand + Toggle */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-14 items-center border-b border-border shrink-0',
|
'flex h-14 items-center border-b border-border shrink-0',
|
||||||
@@ -133,7 +195,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggleSidebar}
|
||||||
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
|
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
|
||||||
>
|
>
|
||||||
@@ -159,34 +221,38 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavRow
|
<NavRow
|
||||||
key={item.href}
|
item={dashboardItem}
|
||||||
item={item}
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={isItemActive(item)}
|
active={isItemActive(dashboardItem)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{isAdmin && (
|
{collapsed
|
||||||
<>
|
? visibleSections.map((section) => (
|
||||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
<CollapsedSectionFlyout
|
||||||
{adminItems
|
key={section.label}
|
||||||
.filter(
|
section={section}
|
||||||
(item) =>
|
isItemActive={isItemActive}
|
||||||
!item.requiredPermission ||
|
active={sectionContainsActive(section)}
|
||||||
user?.permisos.includes(item.requiredPermission),
|
|
||||||
)
|
|
||||||
.map((item) => (
|
|
||||||
<NavRow
|
|
||||||
key={item.href}
|
|
||||||
item={item}
|
|
||||||
collapsed={collapsed}
|
|
||||||
active={isItemActive(item)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
</>
|
: visibleSections.map((section) => {
|
||||||
)}
|
const containsActive = sectionContainsActive(section)
|
||||||
|
const userPrefCollapsed = isSectionCollapsed(section.label)
|
||||||
|
// Auto-expand override: if the active route lives here, keep it open
|
||||||
|
// regardless of persisted preference.
|
||||||
|
const expanded = containsActive ? true : !userPrefCollapsed
|
||||||
|
return (
|
||||||
|
<ExpandedSection
|
||||||
|
key={section.label}
|
||||||
|
section={section}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggle={() => toggleSection(section.label)}
|
||||||
|
toggleDisabled={containsActive}
|
||||||
|
isItemActive={isItemActive}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
@@ -210,41 +276,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
|
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Disabled item
|
|
||||||
if (item.disabled) {
|
|
||||||
const content = (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
baseClasses,
|
|
||||||
'text-muted-foreground/70 cursor-not-allowed opacity-60',
|
|
||||||
)}
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 truncate">{item.label}</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
|
|
||||||
Próx.
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!collapsed) return content
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{item.label}{' '}
|
|
||||||
<span className="text-muted-foreground">· Próximamente</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active link
|
|
||||||
const link = (
|
const link = (
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
@@ -256,7 +287,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
)}
|
)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{/* Active indicator bar (left edge) when expanded */}
|
|
||||||
{active && !collapsed && (
|
{active && !collapsed && (
|
||||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
|
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -274,21 +304,138 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionLabel({
|
/* ── Expanded mode — collapsible section with chevron toggle ──────── */
|
||||||
collapsed,
|
|
||||||
children,
|
interface ExpandedSectionProps {
|
||||||
}: {
|
section: NavSection
|
||||||
collapsed: boolean
|
expanded: boolean
|
||||||
children: React.ReactNode
|
onToggle: () => void
|
||||||
}) {
|
/** When true, the header is not clickable (because the section contains the active route and must stay open). */
|
||||||
if (collapsed) {
|
toggleDisabled: boolean
|
||||||
return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
|
isItemActive: (item: NavItem) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExpandedSection({
|
||||||
|
section,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
toggleDisabled,
|
||||||
|
isItemActive,
|
||||||
|
}: ExpandedSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 pb-1 px-3">
|
<div className="pt-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
<button
|
||||||
{children}
|
type="button"
|
||||||
</span>
|
onClick={toggleDisabled ? undefined : onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between pt-1 pb-1 px-3 rounded-md',
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60',
|
||||||
|
toggleDisabled
|
||||||
|
? 'cursor-default'
|
||||||
|
: 'hover:text-muted-foreground transition-colors cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{section.label}</span>
|
||||||
|
{!toggleDisabled && (
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3 shrink-0 transition-transform duration-200',
|
||||||
|
expanded ? 'rotate-0' : '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<NavRow
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
collapsed={false}
|
||||||
|
active={isItemActive(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Collapsed mode — group icon + fly-out hover panel ──────────── */
|
||||||
|
|
||||||
|
interface CollapsedSectionFlyoutProps {
|
||||||
|
section: NavSection
|
||||||
|
isItemActive: (item: NavItem) => boolean
|
||||||
|
/** When true, the group icon shows an active indicator (contains the active route). */
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedSectionFlyout({
|
||||||
|
section,
|
||||||
|
isItemActive,
|
||||||
|
active,
|
||||||
|
}: CollapsedSectionFlyoutProps) {
|
||||||
|
const GroupIcon = section.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={120} closeDelay={80}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={section.label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center justify-center h-10 w-10 mx-auto rounded-md transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GroupIcon className="h-4 w-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-56 p-2 z-[60]"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="px-2 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||||
|
{section.label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = isItemActive(item)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
role="menuitem"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-1.5 rounded-md text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
238
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal file
238
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SidebarNav } from '../AppSidebar'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado inicial del authStore para cada test.
|
||||||
|
* El rol admin + set de permisos completos activa todas las secciones.
|
||||||
|
*/
|
||||||
|
function setUser(overrides: Partial<{ rol: string; permisos: string[] }> = {}) {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin Test',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: [
|
||||||
|
'administracion:auditoria:ver',
|
||||||
|
'administracion:medios:gestionar',
|
||||||
|
'administracion:secciones:gestionar',
|
||||||
|
'administracion:puntos_de_venta:gestionar',
|
||||||
|
'catalogo:rubros:gestionar',
|
||||||
|
'catalogo:tipos:gestionar',
|
||||||
|
'catalogo:productos:gestionar',
|
||||||
|
'tasacion:caracteres_especiales:gestionar',
|
||||||
|
],
|
||||||
|
mustChangePassword: false,
|
||||||
|
...overrides,
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar(initialPath = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<SidebarNav forceExpanded />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setUser()
|
||||||
|
// Clean per-section collapse preferences between tests
|
||||||
|
window.localStorage.removeItem('sidebar-sections-collapsed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Dashboard visible para todo usuario autenticado', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('No muestra items disabled con badge "Próx." (limpieza)', () => {
|
||||||
|
renderSidebar()
|
||||||
|
// Los 4 items removidos del sidebar: Ventas, Tasación (nivel top), Integraciones, Administración (nivel top)
|
||||||
|
expect(screen.queryByText(/próx/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^ventas$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^integraciones$/i })).not.toBeInTheDocument()
|
||||||
|
// "Administración" como link top-level también se elimina (queda solo como label de sección)
|
||||||
|
expect(screen.queryByRole('link', { name: /^administración$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Muestra las 4 secciones agrupadas para admin con todos los permisos', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText('Seguridad')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Maestros')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Catálogo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Tasación')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cada item vive en la sección correcta (tras expandir todas las secciones)', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
// Expandir manualmente las 4 secciones (arrancan colapsadas por default)
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'sidebar-sections-collapsed',
|
||||||
|
JSON.stringify({ Seguridad: false, Maestros: false, Catálogo: false, Tasación: false }),
|
||||||
|
)
|
||||||
|
renderSidebar()
|
||||||
|
void user // keep setup import used
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
expect(screen.getByRole('link', { name: /usuarios/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /crear usuario/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /roles/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /permisos/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /auditoría/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Maestros
|
||||||
|
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /^secciones$/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /puntos de venta/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Catálogo
|
||||||
|
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /tipos de producto/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /^productos$/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Tasación
|
||||||
|
expect(screen.getByRole('link', { name: /caracteres tasables/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Oculta secciones sin items permitidos (ej: user sin permisos de catálogo)', () => {
|
||||||
|
setUser({
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: [
|
||||||
|
// Solo permisos de Seguridad + Maestros
|
||||||
|
'administracion:medios:gestionar',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
|
||||||
|
// Seguridad se muestra (Usuarios/Crear Usuario/Roles/Permisos no requieren permiso custom)
|
||||||
|
expect(screen.getByText('Seguridad')).toBeInTheDocument()
|
||||||
|
// Maestros se muestra (tiene Medios con su permiso)
|
||||||
|
expect(screen.getByText('Maestros')).toBeInTheDocument()
|
||||||
|
// Catálogo desaparece (ningún permiso catalogo:*)
|
||||||
|
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
|
||||||
|
// Tasación desaparece
|
||||||
|
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Items filtrados
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /^secciones$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Usuario no-admin no ve ninguna sección adminOnly', () => {
|
||||||
|
setUser({ rol: 'cajero', permisos: [] })
|
||||||
|
renderSidebar()
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Seguridad')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Maestros')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Marca el item activo según la ruta actual (Caracteres Tasables)', () => {
|
||||||
|
// Ruta activa contenida en Tasación → auto-expand
|
||||||
|
renderSidebar('/admin/tasacion/chargeable-chars')
|
||||||
|
const link = screen.getByRole('link', { name: /caracteres tasables/i })
|
||||||
|
expect(link).toHaveAttribute('aria-current', 'page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Dashboard activo solo en raíz exacta', () => {
|
||||||
|
const { unmount } = renderSidebar('/')
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('aria-current', 'page')
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
expect(screen.getByRole('link', { name: /dashboard/i })).not.toHaveAttribute('aria-current', 'page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Header "SIG-CM 2.0" visible en modo expandido', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText('SIG-CM 2.0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Orden de secciones: Seguridad → Maestros → Catálogo → Tasación', () => {
|
||||||
|
renderSidebar()
|
||||||
|
const nav = screen.getByRole('navigation')
|
||||||
|
const labels = within(nav).getAllByText(/Seguridad|Maestros|Catálogo|Tasación/)
|
||||||
|
expect(labels.map((n) => n.textContent)).toEqual([
|
||||||
|
'Seguridad',
|
||||||
|
'Maestros',
|
||||||
|
'Catálogo',
|
||||||
|
'Tasación',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Collapse per-section (modo expandido) ──────────────────────────────
|
||||||
|
|
||||||
|
it('Default: todas las secciones arrancan colapsadas EXCEPTO la que contiene la ruta activa', () => {
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
// Maestros contiene la ruta activa → expandida → Medios visible
|
||||||
|
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
|
||||||
|
// Otras secciones colapsadas → sus items NO visibles
|
||||||
|
expect(screen.queryByRole('link', { name: /^usuarios$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Header de sección activa NO es toggleable (sin chevron, disabled)', () => {
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
const maestrosBtn = screen.getByRole('button', { name: /maestros/i })
|
||||||
|
expect(maestrosBtn).toBeDisabled()
|
||||||
|
expect(maestrosBtn).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Click en header de sección no-activa expande/colapsa sus items', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
|
||||||
|
// Catálogo arranca colapsado → Rubros NO visible
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click en "Catálogo" → expande
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Click de nuevo → colapsa
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-expanded refleja estado expandido/colapsado', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/admin/medios')
|
||||||
|
|
||||||
|
const catalogoBtn = screen.getByRole('button', { name: /catálogo/i })
|
||||||
|
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
|
||||||
|
await user.click(catalogoBtn)
|
||||||
|
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Ruta en Dashboard (raíz): ninguna sección contiene active → todas colapsadas', () => {
|
||||||
|
renderSidebar('/')
|
||||||
|
expect(screen.queryByRole('link', { name: /^medios$/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Preferencia de collapse persiste en localStorage', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderSidebar('/')
|
||||||
|
|
||||||
|
// Expandir Catálogo → guarda preferencia
|
||||||
|
await user.click(screen.getByRole('button', { name: /catálogo/i }))
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem('sidebar-sections-collapsed')
|
||||||
|
expect(stored).toBeTruthy()
|
||||||
|
const parsed = JSON.parse(stored!)
|
||||||
|
expect(parsed['Catálogo']).toBe(false) // false = expandido
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/web/src/components/ui/hover-card.tsx
Normal file
29
src/web/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Portal>
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll, beforeEach } 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 React from 'react'
|
||||||
|
import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
// Mock ProductTypeSelect so it renders a simple select with a "Global" option
|
||||||
|
// This avoids fetching product-types in form dialog tests
|
||||||
|
vi.mock('../components/ProductTypeSelect', () => ({
|
||||||
|
ProductTypeSelect: ({ value, onValueChange, disabled }: {
|
||||||
|
value: number | null | undefined
|
||||||
|
onValueChange: (v: number | null | undefined) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) => (
|
||||||
|
<select
|
||||||
|
aria-label="Tipo de producto"
|
||||||
|
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="__global__">Global (todos los tipos)</option>
|
||||||
|
<option value="1">Clasificados</option>
|
||||||
|
<option value="2">Notables</option>
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks(); vi.useRealTimers() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function setupFakeTimers() {
|
||||||
|
// Fix today to 2026-04-20 ART
|
||||||
|
// 2026-04-20T15:00:00-03:00 = 2026-04-20T18:00:00Z
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
vi.setSystemTime(new Date('2026-04-20T18:00:00.000Z'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog(
|
||||||
|
mode: 'create' | 'schedulePrice' = 'create',
|
||||||
|
config?: ChargeableCharConfig,
|
||||||
|
onOpenChange = vi.fn(),
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ChargeableCharFormDialog
|
||||||
|
open={true}
|
||||||
|
mode={mode}
|
||||||
|
config={config}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChargeableCharFormDialog — create mode', () => {
|
||||||
|
beforeEach(() => setupFakeTimers())
|
||||||
|
|
||||||
|
it('shows validation error when pricePerUnit is 0', async () => {
|
||||||
|
renderDialog('create')
|
||||||
|
|
||||||
|
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||||
|
await userEvent.clear(priceInput)
|
||||||
|
await userEvent.type(priceInput, '0')
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||||
|
await userEvent.clear(dateInput)
|
||||||
|
await userEvent.type(dateInput, '2026-04-25')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(),
|
||||||
|
{ timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when validFrom is in the past', async () => {
|
||||||
|
renderDialog('create')
|
||||||
|
|
||||||
|
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||||
|
await userEvent.clear(priceInput)
|
||||||
|
await userEvent.type(priceInput, '1.5')
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||||
|
await userEvent.clear(dateInput)
|
||||||
|
await userEvent.type(dateInput, '2026-04-19')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/anterior a hoy/i)).toBeInTheDocument(),
|
||||||
|
{ timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('happy path calls mutation with correct yyyy-MM-dd string payload (productTypeId: null)', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: 1, productTypeId: null, symbol: '$', category: 'Currency',
|
||||||
|
pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog('create', undefined, onOpenChange)
|
||||||
|
|
||||||
|
// Fill symbol
|
||||||
|
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
|
||||||
|
await userEvent.clear(symbolInput)
|
||||||
|
await userEvent.type(symbolInput, '$')
|
||||||
|
|
||||||
|
// Select category via Radix Select
|
||||||
|
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
|
||||||
|
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
|
||||||
|
|
||||||
|
// Fill price
|
||||||
|
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||||
|
await userEvent.clear(priceInput)
|
||||||
|
await userEvent.type(priceInput, '1.5')
|
||||||
|
|
||||||
|
// Fill date
|
||||||
|
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||||
|
await userEvent.clear(dateInput)
|
||||||
|
await userEvent.type(dateInput, '2026-04-25')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody).toBeTruthy()
|
||||||
|
const body = capturedBody as Record<string, unknown>
|
||||||
|
expect(body['validFrom']).toBe('2026-04-25')
|
||||||
|
expect(typeof body['validFrom']).toBe('string')
|
||||||
|
// productTypeId defaults to null (Global)
|
||||||
|
expect(body['productTypeId']).toBeNull()
|
||||||
|
}, { timeout: 8000 })
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 })
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
it('shows inline message on server 409 (ForwardOnly)', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ error: 'chargeable_char_forward_only', message: 'No se pueden retrodatar precios.' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderDialog('create')
|
||||||
|
|
||||||
|
// Fill symbol
|
||||||
|
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
|
||||||
|
await userEvent.clear(symbolInput)
|
||||||
|
await userEvent.type(symbolInput, '$')
|
||||||
|
|
||||||
|
// Select category
|
||||||
|
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
|
||||||
|
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
|
||||||
|
|
||||||
|
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||||
|
await userEvent.clear(priceInput)
|
||||||
|
await userEvent.type(priceInput, '1.5')
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||||
|
await userEvent.clear(dateInput)
|
||||||
|
await userEvent.type(dateInput, '2026-04-25')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/retrodatar/i)).toBeInTheDocument(),
|
||||||
|
{ timeout: 8000 })
|
||||||
|
}, 15000)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ChargeableCharFormDialog — schedulePrice mode', () => {
|
||||||
|
beforeEach(() => setupFakeTimers())
|
||||||
|
|
||||||
|
it('hides symbol and category inputs (read-only mode)', () => {
|
||||||
|
const existingConfig: ChargeableCharConfig = {
|
||||||
|
id: 5, productTypeId: null, symbol: '%', category: 'Percentage',
|
||||||
|
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||||
|
}
|
||||||
|
renderDialog('schedulePrice', existingConfig)
|
||||||
|
|
||||||
|
// Should not show editable symbol/category inputs in schedulePrice mode
|
||||||
|
expect(screen.queryByLabelText(/símbolo/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText(/categoría/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Price and date should still be present
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /precio/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('happy path schedulePrice calls PUT endpoint with correct payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ created: { id: 6, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingConfig: ChargeableCharConfig = {
|
||||||
|
id: 5, productTypeId: null, symbol: '%', category: 'Percentage',
|
||||||
|
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||||
|
}
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog('schedulePrice', existingConfig, onOpenChange)
|
||||||
|
|
||||||
|
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||||
|
await userEvent.clear(priceInput)
|
||||||
|
await userEvent.type(priceInput, '2')
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||||
|
await userEvent.clear(dateInput)
|
||||||
|
await userEvent.type(dateInput, '2026-04-25')
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody).toBeTruthy()
|
||||||
|
const body = capturedBody as Record<string, unknown>
|
||||||
|
expect(body['newValidFrom']).toBe('2026-04-25')
|
||||||
|
}, { timeout: 5000 })
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 React from 'react'
|
||||||
|
import { ChargeableCharsTable } from '../components/ChargeableCharsTable'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
// Mock ProductTypeSelect to avoid fetching product-types in table tests
|
||||||
|
vi.mock('../components/ProductTypeSelect', () => ({
|
||||||
|
ProductTypeSelect: ({ value, onValueChange, 'aria-label': ariaLabel }: {
|
||||||
|
value: number | null | undefined
|
||||||
|
onValueChange: (v: number | null | undefined) => void
|
||||||
|
'aria-label'?: string
|
||||||
|
}) => (
|
||||||
|
<select
|
||||||
|
aria-label={ariaLabel ?? 'Tipo de producto'}
|
||||||
|
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="__all__">Todos los tipos</option>
|
||||||
|
<option value="__global__">Global</option>
|
||||||
|
<option value="1">Clasificados</option>
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): ChargeableCharConfig {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
productTypeId: null,
|
||||||
|
symbol: '$',
|
||||||
|
category: 'Currency',
|
||||||
|
pricePerUnit: 1.5,
|
||||||
|
validFrom: '2026-01-01',
|
||||||
|
validTo: null,
|
||||||
|
isActive: true,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderTable(
|
||||||
|
configs: ChargeableCharConfig[],
|
||||||
|
onSchedulePrice = vi.fn(),
|
||||||
|
onDeactivate = vi.fn(),
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ChargeableCharsTable
|
||||||
|
configs={configs}
|
||||||
|
total={configs.length}
|
||||||
|
page={1}
|
||||||
|
pageSize={20}
|
||||||
|
onPageChange={vi.fn()}
|
||||||
|
productTypeId={undefined}
|
||||||
|
activeOnly={true}
|
||||||
|
onProductTypeChange={vi.fn()}
|
||||||
|
onActiveOnlyChange={vi.fn()}
|
||||||
|
onSchedulePrice={onSchedulePrice}
|
||||||
|
onDeactivate={onDeactivate}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChargeableCharsTable', () => {
|
||||||
|
it('renders rows from query result — symbol and category visible', () => {
|
||||||
|
renderTable([
|
||||||
|
makeConfig({ id: 1, symbol: '$', category: 'Currency' }),
|
||||||
|
makeConfig({ id: 2, symbol: '%', category: 'Percentage' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(screen.getByText('$')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('%')).toBeInTheDocument()
|
||||||
|
// Category is displayed with localized label "Moneda ($)"
|
||||||
|
expect(screen.getByText('Moneda ($)')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays "Global" when productTypeId is null', () => {
|
||||||
|
renderTable([makeConfig({ productTypeId: null })])
|
||||||
|
// Multiple "Global" texts may exist (table cell + select option) — assert at least one is present
|
||||||
|
expect(screen.getAllByText('Global').length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Vigente" badge for rows with validTo === null', () => {
|
||||||
|
renderTable([makeConfig({ validTo: null, isActive: true })])
|
||||||
|
expect(screen.getByText('Vigente')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Cerrada" badge for rows with validTo set', () => {
|
||||||
|
renderTable([makeConfig({ validTo: '2026-03-31', isActive: false })])
|
||||||
|
expect(screen.getByText('Cerrada')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats validFrom using formatCivilDate — shows dd/MM/yyyy', () => {
|
||||||
|
renderTable([makeConfig({ validFrom: '2026-01-15' })])
|
||||||
|
expect(screen.getByText('15/01/2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Conditional buttons ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('active row shows Desactivar button but NOT Reactivar', () => {
|
||||||
|
renderTable([makeConfig({ id: 1, isActive: true })])
|
||||||
|
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /reactivar/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inactive row shows Reactivar button but NOT Desactivar', () => {
|
||||||
|
renderTable([makeConfig({ id: 1, isActive: false, validTo: '2026-03-31' })])
|
||||||
|
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /desactivar/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Eliminar button is visible for both active and inactive rows', () => {
|
||||||
|
renderTable([
|
||||||
|
makeConfig({ id: 1, isActive: true }),
|
||||||
|
makeConfig({ id: 2, isActive: false, validTo: '2026-03-31' }),
|
||||||
|
])
|
||||||
|
const eliminarBtns = screen.getAllByRole('button', { name: /eliminar/i })
|
||||||
|
expect(eliminarBtns).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking Desactivar calls onDeactivate with the correct config', async () => {
|
||||||
|
const onDeactivate = vi.fn()
|
||||||
|
const config = makeConfig({ id: 5, isActive: true, symbol: '€' })
|
||||||
|
renderTable([config], vi.fn(), onDeactivate)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
expect(onDeactivate).toHaveBeenCalledWith(expect.objectContaining({ id: 5, symbol: '€' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking Reactivar calls PATCH /reactivate endpoint', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => {
|
||||||
|
calls.push(true)
|
||||||
|
return HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// Also mock the list invalidation re-fetch
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const config = makeConfig({ id: 7, isActive: false, validTo: '2026-03-31' })
|
||||||
|
renderTable([config])
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /reactivar/i }))
|
||||||
|
await waitFor(() => expect(calls.length).toBe(1), { timeout: 5000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated This file is kept for reference. Tests have moved to CopyToAllProductTypesDialog.test.tsx
|
||||||
|
* The CopyToAllMediaDialog component has been renamed to CopyToAllProductTypesDialog.
|
||||||
|
* This file re-exports the new test suite so the old path doesn't break CI.
|
||||||
|
*/
|
||||||
|
// Re-run the same suite but importing the new component
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
|
||||||
|
import type { ProductTypeListItem } from '../../product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
function makeProductType(id: number, nombre: string): ProductTypeListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
nombre,
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypes = [
|
||||||
|
makeProductType(1, 'La Nación'),
|
||||||
|
makeProductType(2, 'Clarín'),
|
||||||
|
makeProductType(3, 'Infobae'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderDialog(onOpenChange = vi.fn()) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||||
|
HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<CopyToAllProductTypesDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
symbol="$"
|
||||||
|
pricePerUnit={1.5}
|
||||||
|
validFrom="2026-04-25"
|
||||||
|
category="Currency"
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CopyToAllMediaDialog (renamed → CopyToAllProductTypesDialog)', () => {
|
||||||
|
it('shows preview of symbol, price, and validFrom', async () => {
|
||||||
|
renderDialog()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
expect(screen.getByText('$')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1.5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('25/04/2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm with 3 product types selected calls create mutation 3 times', async () => {
|
||||||
|
const createCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
createCalls.push(body)
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ id: createCalls.length, productTypeId: (body as Record<string, unknown>)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderDialog()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i })
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel button closes without making API calls', async () => {
|
||||||
|
const createCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
|
createCalls.push(await request.json())
|
||||||
|
return HttpResponse.json({}, { status: 201 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog(onOpenChange)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
|
||||||
|
await userEvent.click(cancelBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
|
||||||
|
expect(createCalls.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
|
||||||
|
import type { ProductTypeListItem } from '../../product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
function makeProductType(id: number, nombre: string): ProductTypeListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
nombre,
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypes = [
|
||||||
|
makeProductType(1, 'Clasificados'),
|
||||||
|
makeProductType(2, 'Notables'),
|
||||||
|
makeProductType(3, 'Fúnebres'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderDialog(onOpenChange = vi.fn()) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||||
|
HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<CopyToAllProductTypesDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
symbol="$"
|
||||||
|
pricePerUnit={1.5}
|
||||||
|
validFrom="2026-04-25"
|
||||||
|
category="Currency"
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CopyToAllProductTypesDialog', () => {
|
||||||
|
it('shows preview of symbol, price, and validFrom', async () => {
|
||||||
|
renderDialog()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Preview info
|
||||||
|
expect(screen.getByText('$')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1.5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('25/04/2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm with 3 product types calls create mutation 3 times', async () => {
|
||||||
|
const createCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
createCalls.push(body)
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ id: createCalls.length, productTypeId: (body as Record<string, unknown>)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderDialog()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i })
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel button closes without making API calls', async () => {
|
||||||
|
const createCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
|
createCalls.push(await request.json())
|
||||||
|
return HttpResponse.json({}, { status: 201 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog(onOpenChange)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
|
||||||
|
await userEvent.click(cancelBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
|
||||||
|
expect(createCalls.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 { DeleteChargeableCharConfigDialog } from '../components/DeleteChargeableCharConfigDialog'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderDialog(
|
||||||
|
configId = 1,
|
||||||
|
symbol = '$',
|
||||||
|
onOpenChange = vi.fn(),
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DeleteChargeableCharConfigDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
configId={configId}
|
||||||
|
symbol={symbol}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeleteChargeableCharConfigDialog', () => {
|
||||||
|
it('renders dialog with symbol in warning text', async () => {
|
||||||
|
renderDialog(1, '$')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText(/eliminará permanentemente/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/'\$'/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/no está en uso/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm button calls delete mutation and shows success toast', async () => {
|
||||||
|
const { toast } = await import('sonner')
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/5`, () =>
|
||||||
|
HttpResponse.json({ id: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog(5, '%', onOpenChange)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /eliminar/i })
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 })
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('%'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel button closes dialog without calling mutation', async () => {
|
||||||
|
const deleteCalls: unknown[] = []
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/1`, async () => {
|
||||||
|
deleteCalls.push(true)
|
||||||
|
return HttpResponse.json({ id: 1 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
renderDialog(1, '$', onOpenChange)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
|
||||||
|
await userEvent.click(cancelBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
|
||||||
|
expect(deleteCalls.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { SymbolInput } from '../components/SymbolInput'
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
describe('SymbolInput — emoji blocking', () => {
|
||||||
|
it('typing ASCII chars updates value via onChange', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<SymbolInput value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await userEvent.type(input, '$')
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('typing an emoji does NOT call onChange with emoji content (emoji blocked)', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<SymbolInput value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// userEvent.type fires change events for each char; emoji chars may be split into surrogates.
|
||||||
|
// The contract: onChange must never be called with a value containing an Extended_Pictographic char.
|
||||||
|
await userEvent.type(input, '😀')
|
||||||
|
|
||||||
|
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||||
|
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
|
||||||
|
expect(hasEmoji).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasting a string with emoji — onChange NOT called with emoji content', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<SymbolInput value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await userEvent.click(input)
|
||||||
|
// userEvent.paste triggers onPaste handler with the given text
|
||||||
|
await userEvent.paste('😀')
|
||||||
|
|
||||||
|
// onChange must NOT have been called with an emoji
|
||||||
|
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||||
|
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
|
||||||
|
expect(hasEmoji).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasting normal text (no emoji) allows value update', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<SymbolInput value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await userEvent.click(input)
|
||||||
|
// Paste normal ASCII — should go through onChange
|
||||||
|
await userEvent.paste('$')
|
||||||
|
|
||||||
|
// onChange may be called with '$' or the merged result
|
||||||
|
// The key assertion: no rejection for non-emoji
|
||||||
|
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||||
|
const allNonEmoji = calls.every((v) => !/\p{Extended_Pictographic}/u.test(v))
|
||||||
|
expect(allNonEmoji).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('value is capped at 4 characters — 5th char is rejected via onChange not called with 5+ chars', async () => {
|
||||||
|
// Start with value of 4 chars already set
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<SymbolInput value="$$$$" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// DOM value is controlled at 4 chars; any additional char should be blocked
|
||||||
|
await userEvent.type(input, '$')
|
||||||
|
|
||||||
|
// onChange should NOT be called with a 5-char string
|
||||||
|
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||||
|
const tooLong = calls.some((v) => v.length > 4)
|
||||||
|
expect(tooLong).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
193
src/web/src/features/chargeableChars/__tests__/hooks.test.ts
Normal file
193
src/web/src/features/chargeableChars/__tests__/hooks.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import React from 'react'
|
||||||
|
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
||||||
|
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
||||||
|
import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig'
|
||||||
|
import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig'
|
||||||
|
import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return {
|
||||||
|
qc,
|
||||||
|
wrapper: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useChargeableCharConfigs', () => {
|
||||||
|
it('fetches list and returns paged result', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: [{ id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }],
|
||||||
|
page: 1, pageSize: 20, total: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useChargeableCharConfigs({}), { wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
expect(result.current.data?.items[0].symbol).toBe('$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends productTypeId and activeOnly as query params', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useChargeableCharConfigs({ productTypeId: 3, activeOnly: true }),
|
||||||
|
{ wrapper },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(capturedUrl).toContain('productTypeId=3')
|
||||||
|
expect(capturedUrl).toContain('activeOnly=true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useSchedulePriceChange', () => {
|
||||||
|
it('on success invalidates both list and byId query keys', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
created: { id: 6, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true },
|
||||||
|
closed: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { qc, wrapper } = makeWrapper()
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSchedulePriceChange(5), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({ newPricePerUnit: 2.0, newValidFrom: '2026-05-01' })
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const listInvalidated = invalidateSpy.mock.calls.some(
|
||||||
|
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'),
|
||||||
|
)
|
||||||
|
expect(listInvalidated).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useReactivateChargeableCharConfig', () => {
|
||||||
|
it('happy path — returns response and invalidates list + byId', async () => {
|
||||||
|
server.use(
|
||||||
|
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () =>
|
||||||
|
HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { qc, wrapper } = makeWrapper()
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => { result.current.mutate(7) })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data?.id).toBe(7)
|
||||||
|
|
||||||
|
const listInvalidated = invalidateSpy.mock.calls.some(
|
||||||
|
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'),
|
||||||
|
)
|
||||||
|
expect(listInvalidated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('409 ALREADY_ACTIVE — throws ReactivationNotAllowedError with correct reason', async () => {
|
||||||
|
server.use(
|
||||||
|
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/8/reactivate`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'ALREADY_ACTIVE', message: 'El registro ya está activo' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => { result.current.mutate(8) })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
|
||||||
|
expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError)
|
||||||
|
expect((result.current.error as ReactivationNotAllowedError).reason).toBe('ALREADY_ACTIVE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('409 VIGENTE_EXISTS — throws ReactivationNotAllowedError with correct reason', async () => {
|
||||||
|
server.use(
|
||||||
|
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/9/reactivate`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'VIGENTE_EXISTS', message: 'Ya existe un registro activo' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => { result.current.mutate(9) })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
|
||||||
|
expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError)
|
||||||
|
expect((result.current.error as ReactivationNotAllowedError).reason).toBe('VIGENTE_EXISTS')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeleteChargeableCharConfig', () => {
|
||||||
|
it('happy path — returns {id} and invalidates list + removes byId', async () => {
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/10`, () =>
|
||||||
|
HttpResponse.json({ id: 10 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { qc, wrapper } = makeWrapper()
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
const removeSpy = vi.spyOn(qc, 'removeQueries')
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => { result.current.mutate(10) })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data?.id).toBe(10)
|
||||||
|
|
||||||
|
const listInvalidated = invalidateSpy.mock.calls.some(
|
||||||
|
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'),
|
||||||
|
)
|
||||||
|
expect(listInvalidated).toBe(true)
|
||||||
|
expect(removeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('404 — mutation transitions to error state', async () => {
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/99`, () =>
|
||||||
|
HttpResponse.json({ message: 'Not found' }, { status: 404 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper })
|
||||||
|
|
||||||
|
await act(async () => { result.current.mutate(99) })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ChargeableCharConfig, CreateChargeableCharConfigRequest } from '../types'
|
||||||
|
|
||||||
|
export async function createChargeableCharConfig(
|
||||||
|
payload: CreateChargeableCharConfigRequest,
|
||||||
|
): Promise<ChargeableCharConfig> {
|
||||||
|
const response = await axiosClient.post<ChargeableCharConfig>(
|
||||||
|
'/api/v1/admin/chargeable-chars',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateChargeableCharConfig(id: number): Promise<void> {
|
||||||
|
await axiosClient.patch(`/api/v1/admin/chargeable-chars/${id}/deactivate`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { DeleteChargeableCharConfigResponse } from '../types'
|
||||||
|
|
||||||
|
export async function deleteChargeableCharConfig(
|
||||||
|
id: number,
|
||||||
|
): Promise<DeleteChargeableCharConfigResponse> {
|
||||||
|
const response = await axiosClient.delete<DeleteChargeableCharConfigResponse>(
|
||||||
|
`/api/v1/admin/chargeable-chars/${id}`,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
|
export async function getChargeableCharConfig(id: number): Promise<ChargeableCharConfig> {
|
||||||
|
const response = await axiosClient.get<ChargeableCharConfig>(
|
||||||
|
`/api/v1/admin/chargeable-chars/${id}`,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ChargeableCharConfig, ChargeableCharConfigsQuery, PagedResult } from '../types'
|
||||||
|
|
||||||
|
export async function listChargeableCharConfigs(
|
||||||
|
query: ChargeableCharConfigsQuery,
|
||||||
|
): Promise<PagedResult<ChargeableCharConfig>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.productTypeId !== undefined) params.set('productTypeId', String(query.productTypeId))
|
||||||
|
if (query.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly))
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<ChargeableCharConfig>>(
|
||||||
|
'/api/v1/admin/chargeable-chars',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import type { ReactivateChargeableCharConfigResponse } from '../types'
|
||||||
|
|
||||||
|
export type ReactivationNotAllowedReason =
|
||||||
|
| 'ALREADY_ACTIVE'
|
||||||
|
| 'VIGENTE_EXISTS'
|
||||||
|
| 'POSTERIOR_ROWS_EXIST'
|
||||||
|
|
||||||
|
export class ReactivationNotAllowedError extends Error {
|
||||||
|
reason: ReactivationNotAllowedReason
|
||||||
|
|
||||||
|
constructor(reason: ReactivationNotAllowedReason, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ReactivationNotAllowedError'
|
||||||
|
this.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reactivateChargeableCharConfig(
|
||||||
|
id: number,
|
||||||
|
): Promise<ReactivateChargeableCharConfigResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axiosClient.patch<ReactivateChargeableCharConfigResponse>(
|
||||||
|
`/api/v1/admin/chargeable-chars/${id}/reactivate`,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
isAxiosError(err) &&
|
||||||
|
err.response?.status === 409 &&
|
||||||
|
err.response.data?.code === 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED'
|
||||||
|
) {
|
||||||
|
const reason: ReactivationNotAllowedReason = err.response.data?.reason ?? 'ALREADY_ACTIVE'
|
||||||
|
const message: string = err.response.data?.message ?? 'Reactivación no permitida.'
|
||||||
|
throw new ReactivationNotAllowedError(reason, message)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SchedulePriceChangeRequest, SchedulePriceChangeResponse } from '../types'
|
||||||
|
|
||||||
|
export async function schedulePriceChange(
|
||||||
|
id: number,
|
||||||
|
payload: SchedulePriceChangeRequest,
|
||||||
|
): Promise<SchedulePriceChangeResponse> {
|
||||||
|
const response = await axiosClient.put<SchedulePriceChangeResponse>(
|
||||||
|
`/api/v1/admin/chargeable-chars/${id}/price`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
18
src/web/src/features/chargeableChars/categories.ts
Normal file
18
src/web/src/features/chargeableChars/categories.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// PRC-001 — ChargeableCharCategory constants
|
||||||
|
import type { ChargeableCharCategory } from './types'
|
||||||
|
|
||||||
|
export const CHARGEABLE_CHAR_CATEGORIES: ChargeableCharCategory[] = [
|
||||||
|
'Currency',
|
||||||
|
'Percentage',
|
||||||
|
'Exclamation',
|
||||||
|
'Question',
|
||||||
|
'Other',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<ChargeableCharCategory, string> = {
|
||||||
|
Currency: 'Moneda ($)',
|
||||||
|
Percentage: 'Porcentaje (%)',
|
||||||
|
Exclamation: 'Exclamación (!)',
|
||||||
|
Question: 'Pregunta (?)',
|
||||||
|
Other: 'Otro',
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
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 { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { todayArgentina } from '@/lib/formatters'
|
||||||
|
import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories'
|
||||||
|
import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig'
|
||||||
|
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
||||||
|
import { SymbolInput } from './SymbolInput'
|
||||||
|
import { ProductTypeSelect } from './ProductTypeSelect'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
|
// ─── Emoji regex (same as SymbolInput) ───────────────────────────────────────
|
||||||
|
const EMOJI_REGEX = /\p{Extended_Pictographic}/u
|
||||||
|
|
||||||
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
productTypeId: z.number().nullable().optional(),
|
||||||
|
symbol: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El símbolo es requerido.')
|
||||||
|
.max(4, 'Máximo 4 caracteres.')
|
||||||
|
.refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'),
|
||||||
|
category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], {
|
||||||
|
error: 'La categoría es requerida.',
|
||||||
|
}),
|
||||||
|
pricePerUnit: z.coerce
|
||||||
|
.number('Debe ser un número.')
|
||||||
|
.positive('El precio debe ser mayor a cero.'),
|
||||||
|
validFrom: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La fecha es requerida.')
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
|
||||||
|
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const schedulePriceSchema = z.object({
|
||||||
|
pricePerUnit: z.coerce
|
||||||
|
.number('Debe ser un número.')
|
||||||
|
.positive('El precio debe ser mayor a cero.'),
|
||||||
|
validFrom: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La fecha es requerida.')
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
|
||||||
|
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type CreateFormRaw = {
|
||||||
|
productTypeId?: number | null
|
||||||
|
symbol: string
|
||||||
|
category: string
|
||||||
|
pricePerUnit: string
|
||||||
|
validFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchedulePriceFormRaw = {
|
||||||
|
pricePerUnit: string
|
||||||
|
validFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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; code?: string }
|
||||||
|
if (err.response.status === 409) {
|
||||||
|
return data.message ?? 'No se pueden retrodatar precios. Elegí una fecha posterior.'
|
||||||
|
}
|
||||||
|
if (err.response.status === 400 && data.code === 'CHARGEABLE_CHAR_FORWARD_ONLY') {
|
||||||
|
return 'No se pueden retrodatar precios.'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar.'
|
||||||
|
}
|
||||||
|
return 'Error al guardar. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ChargeableCharFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
mode: 'create' | 'schedulePrice'
|
||||||
|
/** Required when mode is 'schedulePrice' */
|
||||||
|
config?: ChargeableCharConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ChargeableCharFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mode,
|
||||||
|
config,
|
||||||
|
}: ChargeableCharFormDialogProps) {
|
||||||
|
const createMutation = useCreateChargeableCharConfig()
|
||||||
|
const scheduleMutation = useSchedulePriceChange(config?.id ?? 0)
|
||||||
|
|
||||||
|
const isSchedule = mode === 'schedulePrice'
|
||||||
|
const activeMutation = isSchedule ? scheduleMutation : createMutation
|
||||||
|
|
||||||
|
// ── Create form ──────────────────────────────────────────────────────────
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const createForm = useForm<CreateFormRaw>({
|
||||||
|
resolver: zodResolver(createSchema) as any,
|
||||||
|
defaultValues: { productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' },
|
||||||
|
mode: 'onSubmit',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── SchedulePrice form ───────────────────────────────────────────────────
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const scheduleForm = useForm<SchedulePriceFormRaw>({
|
||||||
|
resolver: zodResolver(schedulePriceSchema) as any,
|
||||||
|
defaultValues: { pricePerUnit: '', validFrom: '' },
|
||||||
|
mode: 'onSubmit',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
createForm.reset({ productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' })
|
||||||
|
scheduleForm.reset({ pricePerUnit: '', validFrom: '' })
|
||||||
|
createMutation.reset()
|
||||||
|
scheduleMutation.reset()
|
||||||
|
}
|
||||||
|
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(activeMutation.error)
|
||||||
|
const today = todayArgentina()
|
||||||
|
|
||||||
|
function handleCreateSubmit(values: z.infer<typeof createSchema>) {
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
productTypeId: values.productTypeId ?? null,
|
||||||
|
symbol: values.symbol,
|
||||||
|
category: values.category as ChargeableCharConfig['category'],
|
||||||
|
pricePerUnit: values.pricePerUnit,
|
||||||
|
validFrom: values.validFrom,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => onOpenChange(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScheduleSubmit(values: z.infer<typeof schedulePriceSchema>) {
|
||||||
|
scheduleMutation.mutate(
|
||||||
|
{
|
||||||
|
newPricePerUnit: values.pricePerUnit,
|
||||||
|
newValidFrom: values.validFrom,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => onOpenChange(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = activeMutation.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isSchedule ? 'Programar cambio de precio' : 'Nuevo carácter tasable'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isSchedule
|
||||||
|
? `Programá un nuevo precio para "${config?.symbol}" a partir de la fecha elegida.`
|
||||||
|
: 'Completá los datos para crear un nuevo carácter tasable.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── CREATE MODE ─────────────────────────────────────────────────── */}
|
||||||
|
{!isSchedule && (
|
||||||
|
<Form {...createForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={createForm.handleSubmit(
|
||||||
|
handleCreateSubmit as unknown as Parameters<typeof createForm.handleSubmit>[0],
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* Tipo de producto */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="productTypeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo de producto</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ProductTypeSelect
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => field.onChange(v ?? null)}
|
||||||
|
globalOptionLabel="Global (todos los tipos)"
|
||||||
|
placeholder="Seleccioná un tipo de producto"
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tipo de producto"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Símbolo */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="symbol"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="symbol-input">Símbolo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<SymbolInput
|
||||||
|
id="symbol-input"
|
||||||
|
aria-label="Símbolo"
|
||||||
|
name={field.name}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="ej: $"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{!fieldState.error && <FormMessage />}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Categoría */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="category"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Categoría</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Categoría">
|
||||||
|
<SelectValue placeholder="Seleccioná una categoría" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CHARGEABLE_CHAR_CATEGORIES.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Precio */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="pricePerUnit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Precio por unidad</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0.0001"
|
||||||
|
placeholder="0.0000"
|
||||||
|
aria-label="Precio por unidad"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vigente desde */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="validFrom"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigente desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
min={today}
|
||||||
|
aria-label="Vigente desde"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── SCHEDULE PRICE MODE ─────────────────────────────────────────── */}
|
||||||
|
{isSchedule && (
|
||||||
|
<Form {...scheduleForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={scheduleForm.handleSubmit(
|
||||||
|
handleScheduleSubmit as unknown as Parameters<typeof scheduleForm.handleSubmit>[0],
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* Read-only info */}
|
||||||
|
{config && (
|
||||||
|
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Símbolo: </span>
|
||||||
|
<span className="font-mono font-semibold">{config.symbol}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Categoría: </span>
|
||||||
|
<span>{CATEGORY_LABELS[config.category]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nuevo precio */}
|
||||||
|
<FormField
|
||||||
|
control={scheduleForm.control}
|
||||||
|
name="pricePerUnit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nuevo precio por unidad</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0.0001"
|
||||||
|
placeholder="0.0000"
|
||||||
|
aria-label="Precio por unidad"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vigente desde */}
|
||||||
|
<FormField
|
||||||
|
control={scheduleForm.control}
|
||||||
|
name="validFrom"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigente desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
min={today}
|
||||||
|
aria-label="Vigente desde"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { formatCivilDate } from '@/lib/formatters'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
import { CATEGORY_LABELS } from '../categories'
|
||||||
|
import { useProductTypes } from '../../product-types/hooks/useProductTypes'
|
||||||
|
import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig'
|
||||||
|
import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig'
|
||||||
|
import { ProductTypeSelect } from './ProductTypeSelect'
|
||||||
|
import { DeleteChargeableCharConfigDialog } from './DeleteChargeableCharConfigDialog'
|
||||||
|
|
||||||
|
interface ChargeableCharsTableProps {
|
||||||
|
configs: ChargeableCharConfig[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
productTypeId: number | undefined
|
||||||
|
activeOnly: boolean
|
||||||
|
onProductTypeChange: (productTypeId: number | undefined) => void
|
||||||
|
onActiveOnlyChange: (value: boolean) => void
|
||||||
|
onSchedulePrice: (config: ChargeableCharConfig) => void
|
||||||
|
onDeactivate: (config: ChargeableCharConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReactivationError(err: unknown): string {
|
||||||
|
if (err instanceof ReactivationNotAllowedError) {
|
||||||
|
switch (err.reason) {
|
||||||
|
case 'ALREADY_ACTIVE':
|
||||||
|
return 'El registro ya está activo.'
|
||||||
|
case 'VIGENTE_EXISTS':
|
||||||
|
return 'Ya existe un registro activo para este tipo de producto y símbolo. Modificá ese registro en su lugar.'
|
||||||
|
case 'POSTERIOR_ROWS_EXIST':
|
||||||
|
return 'Existen cambios posteriores al cierre de este registro. Para modificar el precio, usá "Programar cambio de precio".'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'No se pudo reactivar el símbolo. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChargeableCharsTable({
|
||||||
|
configs,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
productTypeId,
|
||||||
|
activeOnly,
|
||||||
|
onProductTypeChange,
|
||||||
|
onActiveOnlyChange,
|
||||||
|
onSchedulePrice,
|
||||||
|
onDeactivate,
|
||||||
|
}: ChargeableCharsTableProps) {
|
||||||
|
const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 })
|
||||||
|
const productTypes = ptData?.items ?? []
|
||||||
|
|
||||||
|
const reactivateMutation = useReactivateChargeableCharConfig()
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ChargeableCharConfig | null>(null)
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
function handleReactivate(config: ChargeableCharConfig) {
|
||||||
|
reactivateMutation.mutate(config.id, {
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(resolveReactivationError(err))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<ChargeableCharConfig>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'productTypeId',
|
||||||
|
header: 'Tipo de Producto',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ptId = row.original.productTypeId
|
||||||
|
if (ptId === null) return <span className="text-muted-foreground">Global</span>
|
||||||
|
const pt = productTypes.find((p) => p.id === ptId)
|
||||||
|
return <span>{pt?.nombre ?? `Tipo ${ptId}`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'symbol',
|
||||||
|
header: 'Símbolo',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-lg">{row.original.symbol}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'category',
|
||||||
|
header: 'Categoría',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{CATEGORY_LABELS[row.original.category] ?? row.original.category}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'pricePerUnit',
|
||||||
|
header: 'Precio/unidad',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('es-AR', {
|
||||||
|
minimumFractionDigits: 4,
|
||||||
|
maximumFractionDigits: 4,
|
||||||
|
}).format(row.original.pricePerUnit)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'validFrom',
|
||||||
|
header: 'Desde',
|
||||||
|
cell: ({ row }) => <span>{formatCivilDate(row.original.validFrom)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'validTo',
|
||||||
|
header: 'Hasta',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{row.original.validTo ? formatCivilDate(row.original.validTo) : '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'isActive',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.isActive ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Vigente
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Cerrada
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const config = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{config.isActive ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSchedulePrice(config)}
|
||||||
|
aria-label="Programar cambio de precio"
|
||||||
|
>
|
||||||
|
Programar precio
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeactivate(config)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
aria-label="Desactivar"
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReactivate(config)}
|
||||||
|
disabled={reactivateMutation.isPending && reactivateMutation.variables === config.id}
|
||||||
|
aria-label="Reactivar"
|
||||||
|
>
|
||||||
|
Reactivar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteTarget(config)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
aria-label="Eliminar"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[productTypes, onSchedulePrice, onDeactivate, reactivateMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<ProductTypeSelect
|
||||||
|
value={productTypeId}
|
||||||
|
onValueChange={(v) => onProductTypeChange(v === null ? undefined : v)}
|
||||||
|
showAllOption={true}
|
||||||
|
allOptionLabel="Todos los tipos"
|
||||||
|
globalOptionLabel="Global"
|
||||||
|
placeholder="Todos los tipos"
|
||||||
|
aria-label="Tipo de producto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="activeOnly"
|
||||||
|
checked={activeOnly}
|
||||||
|
onCheckedChange={onActiveOnlyChange}
|
||||||
|
aria-label="Solo activos"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="activeOnly" className="text-sm">Solo activos</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={configs}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
emptyMessage="Todavía no hay caracteres tasables configurados."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{total} resultado{total !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => onPageChange(page - 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={() => onPageChange(page + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteChargeableCharConfigDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
|
||||||
|
configId={deleteTarget.id}
|
||||||
|
symbol={deleteTarget.symbol}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated Renamed to CopyToAllProductTypesDialog.
|
||||||
|
* This file is kept for backwards compatibility — no code imports it.
|
||||||
|
* @see CopyToAllProductTypesDialog
|
||||||
|
*/
|
||||||
|
export { CopyToAllProductTypesDialog as CopyToAllMediaDialog } from './CopyToAllProductTypesDialog'
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { formatCivilDate } from '@/lib/formatters'
|
||||||
|
import { useProductTypes } from '../../product-types/hooks/useProductTypes'
|
||||||
|
import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig'
|
||||||
|
import type { ChargeableCharCategory } from '../types'
|
||||||
|
|
||||||
|
interface CopyToAllProductTypesDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
symbol: string
|
||||||
|
pricePerUnit: number
|
||||||
|
/** yyyy-MM-dd */
|
||||||
|
validFrom: string
|
||||||
|
category: ChargeableCharCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog that creates rows for all active ProductTypes
|
||||||
|
* with the same symbol/price/validFrom/category.
|
||||||
|
*
|
||||||
|
* Uses Promise.allSettled — if one productType fails, the rest still proceed.
|
||||||
|
* Summary toast shows success/failure counts.
|
||||||
|
*/
|
||||||
|
export function CopyToAllProductTypesDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
symbol,
|
||||||
|
pricePerUnit,
|
||||||
|
validFrom,
|
||||||
|
category,
|
||||||
|
}: CopyToAllProductTypesDialogProps) {
|
||||||
|
const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 })
|
||||||
|
const productTypes = ptData?.items ?? []
|
||||||
|
const createMutation = useCreateChargeableCharConfig()
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (productTypes.length === 0) return
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
// Promise.allSettled: partial failure doesn't block the rest
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
productTypes.map((pt) =>
|
||||||
|
createMutation.mutateAsync({
|
||||||
|
productTypeId: pt.id,
|
||||||
|
symbol,
|
||||||
|
category,
|
||||||
|
pricePerUnit,
|
||||||
|
validFrom,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const succeeded = results.filter((r) => r.status === 'fulfilled').length
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
toast.success(`Copiado a ${succeeded} tipo${succeeded !== 1 ? 's' : ''} exitosamente.`)
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
`${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Copiar a todos los tipos de producto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Se creará una configuración para cada tipo de producto activo con los siguientes datos.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Símbolo: </span>
|
||||||
|
<span className="font-mono font-semibold">{symbol}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Precio/unidad: </span>
|
||||||
|
<span>{pricePerUnit}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Vigente desde: </span>
|
||||||
|
<span>{formatCivilDate(validFrom)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of product types */}
|
||||||
|
{productTypes.length > 0 ? (
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-1 text-sm">
|
||||||
|
{productTypes.map((pt) => (
|
||||||
|
<div key={pt.id} className="flex items-center gap-2 py-1">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span>{pt.nombre}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando tipos de producto...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isProcessing || productTypes.length === 0}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Copiando...' : `Confirmar (${productTypes.length} tipos)`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig'
|
||||||
|
|
||||||
|
interface DeleteChargeableCharConfigDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** The config id to delete */
|
||||||
|
configId: number
|
||||||
|
/** Symbol label for the warning text */
|
||||||
|
symbol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteChargeableCharConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
configId,
|
||||||
|
symbol,
|
||||||
|
}: DeleteChargeableCharConfigDialogProps) {
|
||||||
|
const deleteMutation = useDeleteChargeableCharConfig()
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
deleteMutation.mutate(configId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Símbolo '${symbol}' eliminado correctamente.`)
|
||||||
|
onOpenChange(false)
|
||||||
|
deleteMutation.reset()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('No se pudo eliminar el símbolo. Intentá de nuevo.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(open: boolean) {
|
||||||
|
if (!deleteMutation.isPending) {
|
||||||
|
deleteMutation.reset()
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Eliminar carácter tasable</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Esta acción eliminará permanentemente la tasación del símbolo '{symbol}'.
|
||||||
|
¿Estás seguro?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
La eliminación es posible porque este carácter no está en uso.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleClose(false)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { useProductTypes } from '../../product-types/hooks/useProductTypes'
|
||||||
|
|
||||||
|
const GLOBAL_VALUE = '__global__'
|
||||||
|
const ALL_VALUE = '__all__'
|
||||||
|
|
||||||
|
interface ProductTypeSelectProps {
|
||||||
|
/** Current selected productTypeId. null = "Global", undefined = "All types" (filter mode) */
|
||||||
|
value: number | null | undefined
|
||||||
|
/** Called with null for "Global (todos los tipos)", undefined for "All types", or id */
|
||||||
|
onValueChange: (value: number | null | undefined) => void
|
||||||
|
/** If true — show an "All types" option (undefined) for filter use-case */
|
||||||
|
showAllOption?: boolean
|
||||||
|
/** Label for "all" option */
|
||||||
|
allOptionLabel?: string
|
||||||
|
/** Label for global/null option */
|
||||||
|
globalOptionLabel?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductTypeSelect — renders a shadcn Select populated with active ProductTypes.
|
||||||
|
*
|
||||||
|
* - In "filter" mode (showAllOption=true): adds an "All types" option that returns undefined
|
||||||
|
* - Always includes a "Global (todos los tipos)" option that returns null
|
||||||
|
* - Product types come from GET /api/v1/product-types with activo=true
|
||||||
|
*/
|
||||||
|
export function ProductTypeSelect({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
showAllOption = false,
|
||||||
|
allOptionLabel = 'Todos los tipos',
|
||||||
|
globalOptionLabel = 'Global (todos los tipos)',
|
||||||
|
placeholder = 'Seleccioná un tipo de producto',
|
||||||
|
disabled = false,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: ProductTypeSelectProps) {
|
||||||
|
const { data } = useProductTypes({ activo: true, pageSize: 200 })
|
||||||
|
const productTypes = data?.items ?? []
|
||||||
|
|
||||||
|
function toSelectValue(v: number | null | undefined): string {
|
||||||
|
if (v === undefined) return ALL_VALUE
|
||||||
|
if (v === null) return GLOBAL_VALUE
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSelectValue(sv: string): number | null | undefined {
|
||||||
|
if (sv === ALL_VALUE) return undefined
|
||||||
|
if (sv === GLOBAL_VALUE) return null
|
||||||
|
return Number(sv)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={toSelectValue(value)}
|
||||||
|
onValueChange={(sv) => onValueChange(fromSelectValue(sv))}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={ariaLabel ?? placeholder}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{showAllOption && (
|
||||||
|
<SelectItem value={ALL_VALUE}>{allOptionLabel}</SelectItem>
|
||||||
|
)}
|
||||||
|
<SelectItem value={GLOBAL_VALUE}>{globalOptionLabel}</SelectItem>
|
||||||
|
{productTypes.map((pt) => (
|
||||||
|
<SelectItem key={pt.id} value={String(pt.id)}>
|
||||||
|
{pt.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
// PRC-001 — UDT: emoji blocking regex (Unicode Extended_Pictographic)
|
||||||
|
const EMOJI_REGEX = /\p{Extended_Pictographic}/u
|
||||||
|
|
||||||
|
interface SymbolInputProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
id?: string
|
||||||
|
'aria-label'?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled text input that blocks emoji characters.
|
||||||
|
* Emoji detection via /\p{Extended_Pictographic}/u (spec R4.4).
|
||||||
|
* Max length 4 chars. Server-side validation remains authoritative.
|
||||||
|
*/
|
||||||
|
export function SymbolInput({ value, onChange, error, disabled, placeholder, id, 'aria-label': ariaLabel, name }: SymbolInputProps) {
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const v = e.target.value
|
||||||
|
// Block emojis
|
||||||
|
if (EMOJI_REGEX.test(v)) return
|
||||||
|
// Enforce max length
|
||||||
|
if (v.length > 4) return
|
||||||
|
onChange(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||||
|
const pasted = e.clipboardData.getData('text')
|
||||||
|
if (EMOJI_REGEX.test(pasted)) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
// Normal paste proceeds (length clamping happens via onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
maxLength={4}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder ?? 'ej: $'}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getChargeableCharConfig } from '../api/getChargeableCharConfig'
|
||||||
|
|
||||||
|
export function useChargeableCharConfig(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['chargeableChars', id] as const,
|
||||||
|
queryFn: () => getChargeableCharConfig(id),
|
||||||
|
enabled: id > 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listChargeableCharConfigs } from '../api/listChargeableCharConfigs'
|
||||||
|
import type { ChargeableCharConfigsQuery } from '../types'
|
||||||
|
|
||||||
|
export const chargeableCharConfigsQueryKey = (query: ChargeableCharConfigsQuery) =>
|
||||||
|
['chargeableChars', 'list', query] as const
|
||||||
|
|
||||||
|
export function useChargeableCharConfigs(query: ChargeableCharConfigsQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: chargeableCharConfigsQueryKey(query),
|
||||||
|
queryFn: () => listChargeableCharConfigs(query),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createChargeableCharConfig } from '../api/createChargeableCharConfig'
|
||||||
|
import type { CreateChargeableCharConfigRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateChargeableCharConfig() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateChargeableCharConfigRequest) =>
|
||||||
|
createChargeableCharConfig(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateChargeableCharConfig } from '../api/deactivateChargeableCharConfig'
|
||||||
|
|
||||||
|
export function useDeactivateChargeableCharConfig(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => deactivateChargeableCharConfig(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deleteChargeableCharConfig } from '../api/deleteChargeableCharConfig'
|
||||||
|
|
||||||
|
export function useDeleteChargeableCharConfig() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteChargeableCharConfig(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||||
|
queryClient.removeQueries({ queryKey: ['chargeableChars', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateChargeableCharConfig } from '../api/reactivateChargeableCharConfig'
|
||||||
|
|
||||||
|
export function useReactivateChargeableCharConfig() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateChargeableCharConfig(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { schedulePriceChange } from '../api/schedulePriceChange'
|
||||||
|
import type { SchedulePriceChangeRequest } from '../types'
|
||||||
|
|
||||||
|
export function useSchedulePriceChange(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SchedulePriceChangeRequest) => schedulePriceChange(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
||||||
|
import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig'
|
||||||
|
import { ChargeableCharsTable } from '../components/ChargeableCharsTable'
|
||||||
|
import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
|
||||||
|
import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
|
||||||
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
|
const PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
||||||
|
const DEFAULT_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export function ChargeableCharsPage() {
|
||||||
|
// ── Filter / pagination state ──────────────────────────────────────────────
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [selectedProductTypeId, setSelectedProductTypeId] = useState<number | undefined>(undefined)
|
||||||
|
const [activeOnly, setActiveOnly] = useState(true)
|
||||||
|
|
||||||
|
// ── Dialog state ──────────────────────────────────────────────────────────
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [scheduleConfig, setScheduleConfig] = useState<ChargeableCharConfig | null>(null)
|
||||||
|
const [deactivateId, setDeactivateId] = useState<number | null>(null)
|
||||||
|
const [copyFromConfig, setCopyFromConfig] = useState<ChargeableCharConfig | null>(null)
|
||||||
|
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────────
|
||||||
|
const { data, isLoading } = useChargeableCharConfigs({
|
||||||
|
productTypeId: selectedProductTypeId,
|
||||||
|
activeOnly,
|
||||||
|
page,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deactivateMutation = useDeactivateChargeableCharConfig(deactivateId ?? 0)
|
||||||
|
|
||||||
|
function handleDeactivate(config: ChargeableCharConfig) {
|
||||||
|
setDeactivateId(config.id)
|
||||||
|
// Trigger deactivation immediately (idempotent)
|
||||||
|
deactivateMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Caracteres Tasables</h1>
|
||||||
|
<CanPerform permission={PERMISSION}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCopyFromConfig(data?.items[0] ?? null)}
|
||||||
|
disabled={!data?.items.length}
|
||||||
|
>
|
||||||
|
Copiar a todos los tipos
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
Nuevo carácter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 w-full rounded-md bg-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChargeableCharsTable
|
||||||
|
configs={data?.items ?? []}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
page={page}
|
||||||
|
pageSize={DEFAULT_PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
productTypeId={selectedProductTypeId}
|
||||||
|
activeOnly={activeOnly}
|
||||||
|
onProductTypeChange={(v) => { setSelectedProductTypeId(v); setPage(1) }}
|
||||||
|
onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }}
|
||||||
|
onSchedulePrice={setScheduleConfig}
|
||||||
|
onDeactivate={handleDeactivate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<ChargeableCharFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
mode="create"
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Schedule price dialog */}
|
||||||
|
<ChargeableCharFormDialog
|
||||||
|
open={!!scheduleConfig}
|
||||||
|
mode="schedulePrice"
|
||||||
|
config={scheduleConfig ?? undefined}
|
||||||
|
onOpenChange={(open) => { if (!open) setScheduleConfig(null) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Copy to all product types dialog */}
|
||||||
|
{copyFromConfig && (
|
||||||
|
<CopyToAllProductTypesDialog
|
||||||
|
open={!!copyFromConfig}
|
||||||
|
onOpenChange={(open) => { if (!open) setCopyFromConfig(null) }}
|
||||||
|
symbol={copyFromConfig.symbol}
|
||||||
|
pricePerUnit={copyFromConfig.pricePerUnit}
|
||||||
|
validFrom={copyFromConfig.validFrom}
|
||||||
|
category={copyFromConfig.category}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/web/src/features/chargeableChars/routes.tsx
Normal file
9
src/web/src/features/chargeableChars/routes.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// PRC-001 — chargeableChars feature routes
|
||||||
|
// Route: /admin/tasacion/chargeable-chars
|
||||||
|
// Permission: tasacion:caracteres_especiales:gestionar
|
||||||
|
//
|
||||||
|
// Note: Registration is done in the main router (src/web/src/router.tsx).
|
||||||
|
// This file exports the route path constant for consistency.
|
||||||
|
|
||||||
|
export const CHARGEABLE_CHARS_PATH = '/admin/tasacion/chargeable-chars'
|
||||||
|
export const CHARGEABLE_CHARS_PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
||||||
65
src/web/src/features/chargeableChars/types.ts
Normal file
65
src/web/src/features/chargeableChars/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// PRC-001 — ChargeableCharConfig feature types
|
||||||
|
|
||||||
|
export type ChargeableCharCategory =
|
||||||
|
| 'Currency'
|
||||||
|
| 'Percentage'
|
||||||
|
| 'Exclamation'
|
||||||
|
| 'Question'
|
||||||
|
| 'Other'
|
||||||
|
|
||||||
|
export interface ChargeableCharConfig {
|
||||||
|
id: number
|
||||||
|
productTypeId: number | null
|
||||||
|
symbol: string
|
||||||
|
category: ChargeableCharCategory
|
||||||
|
pricePerUnit: number
|
||||||
|
/** yyyy-MM-dd — Cat2 civil date, NEVER a Date object */
|
||||||
|
validFrom: string
|
||||||
|
/** yyyy-MM-dd | null — null means still active */
|
||||||
|
validTo: string | null
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChargeableCharConfigRequest {
|
||||||
|
productTypeId: number | null
|
||||||
|
symbol: string
|
||||||
|
category: ChargeableCharCategory
|
||||||
|
pricePerUnit: number
|
||||||
|
/** yyyy-MM-dd */
|
||||||
|
validFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulePriceChangeRequest {
|
||||||
|
newPricePerUnit: number
|
||||||
|
/** yyyy-MM-dd */
|
||||||
|
newValidFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulePriceChangeResponse {
|
||||||
|
created: ChargeableCharConfig
|
||||||
|
closed: ChargeableCharConfig | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeableCharConfigsQuery {
|
||||||
|
productTypeId?: number
|
||||||
|
activeOnly?: boolean
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReactivateChargeableCharConfigResponse = {
|
||||||
|
id: number
|
||||||
|
symbol: string
|
||||||
|
productTypeId: number | null
|
||||||
|
pricePerUnit: number
|
||||||
|
validFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteChargeableCharConfigResponse = { id: number }
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { axiosClient } from '@/api/axiosClient'
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
import type { ProductPrice } from '../types'
|
import type { PagedResult, ProductPrice } from '../types'
|
||||||
|
|
||||||
export async function getProductPrices(productId: number): Promise<ProductPrice[]> {
|
export async function getProductPrices(
|
||||||
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`)
|
productId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<PagedResult<ProductPrice>> {
|
||||||
|
const res = await axiosClient.get<PagedResult<ProductPrice>>(
|
||||||
|
`/api/v1/products/${productId}/prices`,
|
||||||
|
{ params: { page, pageSize } },
|
||||||
|
)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AlertCircle, Plus } from 'lucide-react'
|
import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters'
|
|||||||
import { useProductPrices } from '../hooks/useProductPrices'
|
import { useProductPrices } from '../hooks/useProductPrices'
|
||||||
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ProductPriceHistoryProps {
|
interface ProductPriceHistoryProps {
|
||||||
@@ -27,7 +31,9 @@ interface ProductPriceHistoryProps {
|
|||||||
|
|
||||||
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const { data: prices, isLoading, isError } = useProductPrices(productId)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty = !prices?.length
|
const total = prices?.total ?? 0
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
|
const isEmpty = total === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -73,6 +81,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</CanPerform>
|
</CanPerform>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -84,7 +93,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{prices.map((p) => (
|
{prices?.items.map((p) => (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -101,6 +110,33 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {currentPage} de {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setCurrentPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= (totalPages || 1)}
|
||||||
|
onClick={() => setCurrentPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddProductPriceDialog
|
<AddProductPriceDialog
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useAddProductPrice(productId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] })
|
queryClient.invalidateQueries({ queryKey: ['product-prices', productId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user