From b8f1ed8a6806322151d6b60d275b7f7124e5d8a9 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sat, 21 Feb 2026 20:28:50 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Implementaci=C3=B3n=20de=20rangos=20de?= =?UTF-8?q?=20precio=20por=20palabra=20con=20validaci=C3=B3n=20de=20contin?= =?UTF-8?q?uidad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/Pricing/PricingManager.tsx | 143 ++++++++++++++++-- src/SIGCM.Domain/Entities/CategoryPricing.cs | 2 + src/SIGCM.Domain/Entities/WordPricingRange.cs | 10 ++ .../Data/DbInitializer.cs | 13 ++ .../Repositories/PricingRepository.cs | 40 +++-- .../Services/PricingService.cs | 25 ++- 6 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 src/SIGCM.Domain/Entities/WordPricingRange.cs diff --git a/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx b/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx index 61e00e8..7b70461 100644 --- a/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx +++ b/frontend/admin-panel/src/pages/Pricing/PricingManager.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import api from '../../services/api'; -import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight } from 'lucide-react'; +import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight, Plus, Trash2, Info, AlertCircle } from 'lucide-react'; import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils'; // Datos mínimos de un Producto del Catálogo @@ -12,19 +12,36 @@ interface ProductSummary { basePrice: number; } +interface WordRange { + id: number; + fromCount: number; + toCount: number; + pricePerWord: number; +} + interface PricingConfig { + id: number; + categoryId: number; baseWordCount: number; extraWordPrice: number; specialChars: string; specialCharPrice: number; boldSurcharge: number; frameSurcharge: number; + wordRanges: WordRange[]; } // Configuración por defecto const defaultConfig: PricingConfig = { - baseWordCount: 15, extraWordPrice: 0, - specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0 + id: 0, + categoryId: 0, + baseWordCount: 15, + extraWordPrice: 0, + specialChars: '!', + specialCharPrice: 0, + boldSurcharge: 0, + frameSurcharge: 0, + wordRanges: [] }; export default function PricingManager() { @@ -175,24 +192,128 @@ export default function PricingManager() { {/* TARJETA: REGLAS POR PALABRAS */} -
-

- Reglas por Cantidad de Palabras -

+
+
+

+ Tarifación por Cantidad de Palabras +

+
-
-
+
+ {/* CONFIGURACIÓN BÁSICA */} +
+

+ Configuración Base +

- + setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} /> +

Cantidad de palabras ya cubiertas por el Precio Base del Producto.

- + setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} /> +

Se usa si no se definen rangos o si el total supera los rangos definidos.

+ + {/* EDITOR DE RANGOS */} +
+
+

Escalones de Precio (Palabras Extra)

+ +
+ + {config.wordRanges.length === 0 ? ( +
+

No hay rangos definidos. Se cobrará siempre el precio de fallback por cada palabra extra.

+
+ ) : ( +
+ {config.wordRanges.map((range, idx) => ( +
+
+
+ DESDE + {range.fromCount} +
+
+ HASTA + { + const val = parseInt(e.target.value); + const newRanges = [...config.wordRanges]; + newRanges[idx].toCount = val; + + // Actualizar automáticamente el "Desde" del siguiente rango para mantener continuidad + for (let i = idx + 1; i < newRanges.length; i++) { + newRanges[i].fromCount = newRanges[i - 1].toCount + 1; + } + setConfig({ ...config, wordRanges: newRanges }); + }} + /> +
+
+ PRECIO X PALABRA +
+ $ + { + const newRanges = [...config.wordRanges]; + newRanges[idx].pricePerWord = parseFloat(e.target.value); + setConfig({ ...config, wordRanges: newRanges }); + }} + /> +
+
+
+ + +
+ ))} +

+ TIP: Usa un 'Hasta' muy alto (ej: 9999) en el último rango para cubrir todas las palabras extra restantes. +

+
+ )} +
diff --git a/src/SIGCM.Domain/Entities/CategoryPricing.cs b/src/SIGCM.Domain/Entities/CategoryPricing.cs index 36bd351..829b1fb 100644 --- a/src/SIGCM.Domain/Entities/CategoryPricing.cs +++ b/src/SIGCM.Domain/Entities/CategoryPricing.cs @@ -10,4 +10,6 @@ public class CategoryPricing public decimal SpecialCharPrice { get; set; } public decimal BoldSurcharge { get; set; } public decimal FrameSurcharge { get; set; } + + public List WordRanges { get; set; } = new(); } \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/WordPricingRange.cs b/src/SIGCM.Domain/Entities/WordPricingRange.cs new file mode 100644 index 0000000..5c6510a --- /dev/null +++ b/src/SIGCM.Domain/Entities/WordPricingRange.cs @@ -0,0 +1,10 @@ +namespace SIGCM.Domain.Entities; + +public class WordPricingRange +{ + public int Id { get; set; } + public int CategoryPricingId { get; set; } + public int FromCount { get; set; } + public int ToCount { get; set; } // Use a large number or 0 for "infinity" + public decimal PricePerWord { get; set; } +} diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs index 0c51252..ef33f1d 100644 --- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs +++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs @@ -245,6 +245,19 @@ END ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1; END END + + -- Tabla de Rangos de Precios por Palabra + IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'WordPricingRanges') AND type in (N'U')) + BEGIN + CREATE TABLE WordPricingRanges ( + Id INT IDENTITY(1,1) PRIMARY KEY, + CategoryPricingId INT NOT NULL, + FromCount INT NOT NULL, + ToCount INT NOT NULL, + PricePerWord DECIMAL(18,2) NOT NULL, + FOREIGN KEY (CategoryPricingId) REFERENCES CategoryPricing(Id) ON DELETE CASCADE + ); + END "; await connection.ExecuteAsync(migrationSql); diff --git a/src/SIGCM.Infrastructure/Repositories/PricingRepository.cs b/src/SIGCM.Infrastructure/Repositories/PricingRepository.cs index 554ea06..56fe89f 100644 --- a/src/SIGCM.Infrastructure/Repositories/PricingRepository.cs +++ b/src/SIGCM.Infrastructure/Repositories/PricingRepository.cs @@ -16,26 +16,35 @@ public class PricingRepository public async Task GetByCategoryIdAsync(int categoryId) { using var conn = _db.CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM CategoryPricing WHERE CategoryId = @Id", new { Id = categoryId }); + var sql = @" + SELECT * FROM CategoryPricing WHERE CategoryId = @Id; + SELECT * FROM WordPricingRanges WHERE CategoryPricingId = (SELECT Id FROM CategoryPricing WHERE CategoryId = @Id) ORDER BY FromCount;"; + + using var multi = await conn.QueryMultipleAsync(sql, new { Id = categoryId }); + var pricing = await multi.ReadSingleOrDefaultAsync(); + if (pricing != null) + { + pricing.WordRanges = (await multi.ReadAsync()).ToList(); + } + return pricing; } public async Task UpsertPricingAsync(CategoryPricing pricing) { using var conn = _db.CreateConnection(); - // Lógica de "Si existe actualiza, sino inserta" - var exists = await conn.ExecuteScalarAsync( - "SELECT COUNT(1) FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId }); + var exists = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId }); - if (exists > 0) + if (exists != null) { + pricing.Id = exists.Id; var updateSql = @" UPDATE CategoryPricing SET BaseWordCount = @BaseWordCount, ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars, SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge, FrameSurcharge = @FrameSurcharge - WHERE CategoryId = @CategoryId"; + WHERE Id = @Id"; await conn.ExecuteAsync(updateSql, pricing); } else @@ -44,8 +53,21 @@ public class PricingRepository INSERT INTO CategoryPricing (CategoryId, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge) VALUES - (@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge)"; - await conn.ExecuteAsync(insertSql, pricing); + (@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge); + SELECT CAST(SCOPE_IDENTITY() as int);"; + pricing.Id = await conn.QuerySingleAsync(insertSql, pricing); + } + + // Gestionar Rangos de Palabras + await conn.ExecuteAsync("DELETE FROM WordPricingRanges WHERE CategoryPricingId = @Id", new { Id = pricing.Id }); + if (pricing.WordRanges != null && pricing.WordRanges.Any()) + { + foreach (var range in pricing.WordRanges) + { + range.CategoryPricingId = pricing.Id; + } + var rangeSql = "INSERT INTO WordPricingRanges (CategoryPricingId, FromCount, ToCount, PricePerWord) VALUES (@CategoryPricingId, @FromCount, @ToCount, @PricePerWord)"; + await conn.ExecuteAsync(rangeSql, pricing.WordRanges); } } diff --git a/src/SIGCM.Infrastructure/Services/PricingService.cs b/src/SIGCM.Infrastructure/Services/PricingService.cs index 8d2969f..76d2be1 100644 --- a/src/SIGCM.Infrastructure/Services/PricingService.cs +++ b/src/SIGCM.Infrastructure/Services/PricingService.cs @@ -69,7 +69,30 @@ public class PricingService // o suman al conteo de palabras. Aquí implemento: Se cobran APARTE. int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount); - decimal extraWordCost = extraWords * pricing.ExtraWordPrice; + decimal extraWordCost = 0; + + if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 0) + { + // Buscamos el rango aplicable para la cantidad TOTAL de palabras extra + // El ToCount = 0 se interpreta como "sin límite superior" + var applicableRange = pricing.WordRanges.FirstOrDefault(r => + extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0)); + + if (applicableRange != null) + { + extraWordCost = extraWords * applicableRange.PricePerWord; + } + else + { + // Fallback al precio base si no se define rango que cubra la cantidad + extraWordCost = extraWords * pricing.ExtraWordPrice; + } + } + else + { + extraWordCost = extraWords * pricing.ExtraWordPrice; + } + decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice; currentCost += extraWordCost + specialCharCost;