diff --git a/frontend/admin-panel/src/components/Products/ProductModal.tsx b/frontend/admin-panel/src/components/Products/ProductModal.tsx index 1fa97aa..e8f4df7 100644 --- a/frontend/admin-panel/src/components/Products/ProductModal.tsx +++ b/frontend/admin-panel/src/components/Products/ProductModal.tsx @@ -31,6 +31,7 @@ export default function ProductModal({ product, companies, categories, allProduc categoryId: undefined, productTypeId: 4, // Default Physical basePrice: 0, + priceDurationDays: 1, taxRate: 21, sku: '', isActive: true @@ -40,10 +41,12 @@ export default function ProductModal({ product, companies, categories, allProduc const [bundleComponents, setBundleComponents] = useState([]); const [newComponentId, setNewComponentId] = useState(0); const [loading, setLoading] = useState(false); + const [hasDuration, setHasDuration] = useState(false); useEffect(() => { if (product) { setFormData(product); + setHasDuration(product.priceDurationDays > 1); const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId); if (type?.code === 'BUNDLE') { setIsBundle(true); @@ -55,6 +58,13 @@ export default function ProductModal({ product, companies, categories, allProduc useEffect(() => { const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId); setIsBundle(type?.code === 'BUNDLE'); + + // Si es producto físico o combo, forzamos duración 1 (sin periodo) + if (formData.productTypeId === 4 || formData.productTypeId === 6) { + if (formData.priceDurationDays !== 1) { + setFormData(f => ({ ...f, priceDurationDays: 1 })); + } + } }, [formData.productTypeId]); const loadBundleComponents = async (productId: number) => { @@ -188,6 +198,39 @@ export default function ProductModal({ product, companies, categories, allProduc value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} /> + {formData.productTypeId !== 4 && formData.productTypeId !== 6 && ( +
+
{ + const newValue = !hasDuration; + setHasDuration(newValue); + if (!newValue) setFormData({ ...formData, priceDurationDays: 1 }); + }} + className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${hasDuration ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60' + }`} + > +
+ Precio por Duración + Habilitar si el precio base cubre varios días +
+
+
+
+
+ + {hasDuration && ( + + +
+ setFormData({ ...formData, priceDurationDays: Math.max(2, parseInt(e.target.value) || 2) })} /> + Días +
+
+ )} +
+ )} +
setDays(parseInt(e.target.value) || 1)} - /> - + {product?.productTypeId !== 4 && product?.productTypeId !== 6 && ( +
+ +
+ + { + const val = parseInt(e.target.value) || 0; + const step = product?.priceDurationDays || 1; + // Redondear al múltiplo más cercano si es necesario, o simplemente dejarlo + setDays(Math.max(step, val)); + }} + step={product?.priceDurationDays || 1} + /> + +
+ {product && product.priceDurationDays > 1 && ( +

Mínimo: {product.priceDurationDays} días

+ )}
-
+ )}
Costo Estimado
diff --git a/frontend/counter-panel/src/pages/FastEntryPage.tsx b/frontend/counter-panel/src/pages/FastEntryPage.tsx index 56fbd3d..6abf17a 100644 --- a/frontend/counter-panel/src/pages/FastEntryPage.tsx +++ b/frontend/counter-panel/src/pages/FastEntryPage.tsx @@ -231,14 +231,22 @@ export default function FastEntryPage() { productService.getByCategory(parseInt(formData.categoryId)) .then(prods => { setCategoryProducts(prods); - // Auto-seleccionar el primero si solo hay uno - if (prods.length === 1) setSelectedProduct(prods[0]); + if (prods.length > 0) setSelectedProduct(prods[0]); else setSelectedProduct(null); }) .catch(console.error) .finally(() => setLoadingProducts(false)); }, [formData.categoryId]); + // Ajustar días automáticamente al seleccionar un producto con duración especial + useEffect(() => { + if (selectedProduct && selectedProduct.priceDurationDays > 1) { + if (formData.days % selectedProduct.priceDurationDays !== 0 || formData.days < selectedProduct.priceDurationDays) { + setFormData(prev => ({ ...prev, days: selectedProduct.priceDurationDays })); + } + } + }, [selectedProduct]); + const handleSubmit = useCallback(async () => { if (!validate()) return; setShowPaymentModal(true); @@ -566,14 +574,38 @@ export default function FastEntryPage() { />
-
- -
- - setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} /> - + {selectedProduct?.productTypeId !== 4 && selectedProduct?.productTypeId !== 6 && ( +
+ +
+ + { + const val = parseInt(e.target.value) || 0; + const step = selectedProduct?.priceDurationDays || 1; + setFormData({ ...formData, days: Math.max(step, val) }); + }} + step={selectedProduct?.priceDurationDays || 1} + /> + +
-
+ )}
diff --git a/frontend/counter-panel/src/types/Product.ts b/frontend/counter-panel/src/types/Product.ts index be1d0a2..1fb7108 100644 --- a/frontend/counter-panel/src/types/Product.ts +++ b/frontend/counter-panel/src/types/Product.ts @@ -8,6 +8,7 @@ export interface Product { sku?: string; externalId?: string; basePrice: number; + priceDurationDays: number; taxRate: number; currency: string; isActive: boolean; diff --git a/src/SIGCM.API/Controllers/ProductsController.cs b/src/SIGCM.API/Controllers/ProductsController.cs index 14ee44a..b8c1767 100644 --- a/src/SIGCM.API/Controllers/ProductsController.cs +++ b/src/SIGCM.API/Controllers/ProductsController.cs @@ -83,10 +83,6 @@ public class ProductsController : ControllerBase { return BadRequest(new { message = ex.Message }); } - catch (Exception) - { - return StatusCode(500, new { message = "Error inesperado al intentar eliminar el producto." }); - } } // Helper: Obtener lista de empresas para llenar el combo en el Frontend diff --git a/src/SIGCM.Domain/Entities/Product.cs b/src/SIGCM.Domain/Entities/Product.cs index 55e627e..92ff11f 100644 --- a/src/SIGCM.Domain/Entities/Product.cs +++ b/src/SIGCM.Domain/Entities/Product.cs @@ -12,6 +12,7 @@ public class Product public string? ExternalId { get; set; } public decimal BasePrice { get; set; } + public int PriceDurationDays { get; set; } = 1; // Días que cubre el precio base (ej: 30 para autos) public decimal TaxRate { get; set; } // 21.0, 10.5, etc. public string Currency { get; set; } = "ARS"; diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs index ef33f1d..5102395 100644 --- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs +++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs @@ -129,6 +129,11 @@ END ALTER TABLE Products ADD CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId) REFERENCES Categories(Id); END + IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PriceDurationDays' AND Object_ID = Object_ID(N'Products')) + BEGIN + ALTER TABLE Products ADD PriceDurationDays INT NOT NULL DEFAULT 1; + END + -- CategoryPricing Columns IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing')) BEGIN diff --git a/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs index 2c8d131..3b1776e 100644 --- a/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs +++ b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs @@ -73,8 +73,8 @@ public class ProductRepository : IProductRepository { using var conn = _db.CreateConnection(); var sql = @" - INSERT INTO Products (CompanyId, ProductTypeId, CategoryId, Name, Description, SKU, BasePrice, TaxRate, IsActive) - VALUES (@CompanyId, @ProductTypeId, @CategoryId, @Name, @Description, @SKU, @BasePrice, @TaxRate, @IsActive); + INSERT INTO Products (CompanyId, ProductTypeId, CategoryId, Name, Description, SKU, BasePrice, PriceDurationDays, TaxRate, IsActive) + VALUES (@CompanyId, @ProductTypeId, @CategoryId, @Name, @Description, @SKU, @BasePrice, @PriceDurationDays, @TaxRate, @IsActive); SELECT CAST(SCOPE_IDENTITY() as int);"; return await conn.QuerySingleAsync(sql, product); } @@ -84,7 +84,7 @@ public class ProductRepository : IProductRepository using var conn = _db.CreateConnection(); var sql = @" UPDATE Products - SET CategoryId = @CategoryId, Name = @Name, Description = @Description, BasePrice = @BasePrice, TaxRate = @TaxRate, IsActive = @IsActive + SET CategoryId = @CategoryId, Name = @Name, Description = @Description, BasePrice = @BasePrice, PriceDurationDays = @PriceDurationDays, TaxRate = @TaxRate, IsActive = @IsActive WHERE Id = @Id"; await conn.ExecuteAsync(sql, product); } @@ -191,9 +191,15 @@ public class ProductRepository : IProductRepository public async Task DeleteAsync(int id) { using var conn = _db.CreateConnection(); + // 1. Verificar si está siendo usado como componente de un combo - var usedInBundle = await conn.ExecuteScalarAsync( - "SELECT COUNT(1) FROM ProductBundles WHERE ChildProductId = @Id", new { Id = id }); + // Usamos IF OBJECT_ID para que sea robusto si la tabla no existe por algún motivo + var checkBundleSql = @" + IF OBJECT_ID('ProductBundles') IS NOT NULL + SELECT COUNT(1) FROM ProductBundles WHERE ChildProductId = @Id + ELSE + SELECT 0"; + var usedInBundle = await conn.ExecuteScalarAsync(checkBundleSql, new { Id = id }); if (usedInBundle > 0) { @@ -201,28 +207,31 @@ public class ProductRepository : IProductRepository } // 2. Verificar si tiene registros asociados (ej: Listings) - // Buscamos dinámicamente si existe la columna ProductId en Listings o si hay alguna tabla que lo use - var hasSales = await conn.ExecuteScalarAsync(@" - IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = Object_ID('Listings')) + // Usamos SQL dinámico con sp_executesql para evitar errores de compilación si la columna no existe + var checkSalesSql = @" + IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = OBJECT_ID('Listings')) BEGIN - SELECT COUNT(1) FROM Listings WHERE ProductId = @Id + EXEC sp_executesql N'SELECT COUNT(1) FROM Listings WHERE ProductId = @Id', N'@Id INT', @Id END ELSE BEGIN SELECT 0 - END", new { Id = id }); + END"; + + var hasSales = await conn.ExecuteScalarAsync(checkSalesSql, new { Id = id }); if (hasSales > 0) { throw new InvalidOperationException("No se puede eliminar el producto porque ya tiene ventas o registros asociados."); } - // 3. Eliminar registros permitidos - // Borramos precios históricos y relaciones de bundle (si es el padre) - await conn.ExecuteAsync("DELETE FROM ProductPrices WHERE ProductId = @Id", new { Id = id }); - await conn.ExecuteAsync("DELETE FROM ProductBundles WHERE ParentProductId = @Id", new { Id = id }); + // 3. Eliminar registros permitidos (Precios e hijos de bundle si es un combo) + await conn.ExecuteAsync(@" + IF OBJECT_ID('ProductPrices') IS NOT NULL DELETE FROM ProductPrices WHERE ProductId = @Id; + IF OBJECT_ID('ProductBundles') IS NOT NULL DELETE FROM ProductBundles WHERE ParentProductId = @Id; + ", new { Id = id }); - // 4. Eliminar el producto + // 4. Eliminar el producto final await conn.ExecuteAsync("DELETE FROM Products WHERE Id = @Id", new { Id = id }); } } \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Services/PricingService.cs b/src/SIGCM.Infrastructure/Services/PricingService.cs index 76d2be1..a72affb 100644 --- a/src/SIGCM.Infrastructure/Services/PricingService.cs +++ b/src/SIGCM.Infrastructure/Services/PricingService.cs @@ -33,17 +33,23 @@ public class PricingService TotalPrice = 0, Details = "Producto no encontrado." }; - productBasePrice = await _productRepo.GetCurrentPriceAsync(request.ProductId, request.StartDate == default ? DateTime.UtcNow : request.StartDate); + + decimal retrievedPrice = await _productRepo.GetCurrentPriceAsync(request.ProductId, request.StartDate == default ? DateTime.UtcNow : request.StartDate); + + // Si el precio es por X días (ej: 30), el costo diario es Precio / X + int duration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1; + productBasePrice = retrievedPrice / duration; } // 1. Obtener Reglas var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId); - // Si no hay configuración para este rubro, devolvemos 0 o un default seguro + // Si no hay configuración para este rubro, devolvemos al menos el precio base del producto if (pricing == null) return new CalculatePriceResponse { - TotalPrice = 0, - Details = "No hay tarifas configuradas para este rubro." + TotalPrice = productBasePrice * request.Days, + BaseCost = productBasePrice * request.Days, + Details = "El rubro no tiene tarifas configuradas. Se aplica solo el precio base del producto." }; // 2. Análisis del Texto