Implementación de duración de precio por producto y mejoras en cálculo de tarifas
Detalles: - Se añadió PriceDurationDays a la entidad Product. - Actualización de base de datos y repositorios para soportar la nueva propiedad. - Ajuste en PricingService para calcular costos diarios basados en la duración del producto. - Mejora en UI: el selector de días en ventas se adapta a la duración base del producto. - Duración del precio ahora es opcional mediante un interruptor en el modal de producto. - Corrección en DeleteAsync usando SQL dinámico para mayor seguridad. - Cálculo de precio instantáneo al seleccionar el rubro sin requerir texto previo.
This commit is contained in:
@@ -31,6 +31,7 @@ export default function ProductModal({ product, companies, categories, allProduc
|
|||||||
categoryId: undefined,
|
categoryId: undefined,
|
||||||
productTypeId: 4, // Default Physical
|
productTypeId: 4, // Default Physical
|
||||||
basePrice: 0,
|
basePrice: 0,
|
||||||
|
priceDurationDays: 1,
|
||||||
taxRate: 21,
|
taxRate: 21,
|
||||||
sku: '',
|
sku: '',
|
||||||
isActive: true
|
isActive: true
|
||||||
@@ -40,10 +41,12 @@ export default function ProductModal({ product, companies, categories, allProduc
|
|||||||
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
|
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
|
||||||
const [newComponentId, setNewComponentId] = useState<number>(0);
|
const [newComponentId, setNewComponentId] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasDuration, setHasDuration] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product) {
|
if (product) {
|
||||||
setFormData(product);
|
setFormData(product);
|
||||||
|
setHasDuration(product.priceDurationDays > 1);
|
||||||
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
|
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
|
||||||
if (type?.code === 'BUNDLE') {
|
if (type?.code === 'BUNDLE') {
|
||||||
setIsBundle(true);
|
setIsBundle(true);
|
||||||
@@ -55,6 +58,13 @@ export default function ProductModal({ product, companies, categories, allProduc
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
|
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
|
||||||
setIsBundle(type?.code === 'BUNDLE');
|
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]);
|
}, [formData.productTypeId]);
|
||||||
|
|
||||||
const loadBundleComponents = async (productId: number) => {
|
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) })} />
|
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formData.productTypeId !== 4 && formData.productTypeId !== 6 && (
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest leading-none">Precio por Duración</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-bold mt-1">Habilitar si el precio base cubre varios días</span>
|
||||||
|
</div>
|
||||||
|
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDuration ? 'bg-blue-600' : 'bg-slate-300'}`}>
|
||||||
|
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDuration ? 'right-1' : 'left-1'}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasDuration && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1.5 pl-2 border-l-2 border-blue-200 ml-4">
|
||||||
|
<label className="text-[10px] font-black text-blue-500 uppercase tracking-widest ml-1">Cantidad de Días</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input required type="number" min="2" className="w-full p-3 bg-blue-50/50 border-2 border-blue-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm text-blue-600"
|
||||||
|
value={formData.priceDurationDays} onChange={e => setFormData({ ...formData, priceDurationDays: Math.max(2, parseInt(e.target.value) || 2) })} />
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-black text-blue-300 uppercase">Días</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Alicuota IVA (%)</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Alicuota IVA (%)</label>
|
||||||
<select className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm appearance-none"
|
<select className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm appearance-none"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Product {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
priceDurationDays: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useDebounce } from '../../hooks/useDebounce';
|
|||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||||
|
import { productService } from '../../services/productService';
|
||||||
|
import type { Product } from '../../types/Product';
|
||||||
|
|
||||||
interface AdEditorModalProps {
|
interface AdEditorModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,6 +24,7 @@ interface PricingResult {
|
|||||||
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
|
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
|
||||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||||
const [operations, setOperations] = useState<any[]>([]);
|
const [operations, setOperations] = useState<any[]>([]);
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [calculating, setCalculating] = useState(false);
|
const [calculating, setCalculating] = useState(false);
|
||||||
|
|
||||||
@@ -53,9 +56,21 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
|
const [catRes, opRes, prodData] = await Promise.all([
|
||||||
|
api.get('/categories'),
|
||||||
|
api.get('/operations'),
|
||||||
|
productService.getById(productId)
|
||||||
|
]);
|
||||||
setFlatCategories(processCategories(catRes.data));
|
setFlatCategories(processCategories(catRes.data));
|
||||||
setOperations(opRes.data);
|
setOperations(opRes.data);
|
||||||
|
setProduct(prodData);
|
||||||
|
|
||||||
|
// Ajustar días iniciales según la duración del producto
|
||||||
|
if (prodData.priceDurationDays > 1) {
|
||||||
|
setDays(prodData.priceDurationDays);
|
||||||
|
} else {
|
||||||
|
setDays(3);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error cargando configuración", e);
|
console.error("Error cargando configuración", e);
|
||||||
}
|
}
|
||||||
@@ -66,7 +81,10 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
|
|
||||||
// Calculadora de Precio en Tiempo Real
|
// Calculadora de Precio en Tiempo Real
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!categoryId || !text) return;
|
if (!categoryId) {
|
||||||
|
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const calculate = async () => {
|
const calculate = async () => {
|
||||||
setCalculating(true);
|
setCalculating(true);
|
||||||
@@ -88,7 +106,7 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
calculate();
|
calculate();
|
||||||
}, [debouncedText, categoryId, days, styles, startDate]);
|
}, [debouncedText, categoryId, days, styles, startDate, productId]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
||||||
@@ -260,18 +278,42 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, pr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1">
|
{product?.productTypeId !== 4 && product?.productTypeId !== 6 && (
|
||||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Cantidad Días</label>
|
<div className="col-span-1">
|
||||||
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
|
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">
|
||||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">-</button>
|
{product && product.priceDurationDays > 1 ? `Cant. de Módulos (${product.priceDurationDays}d)` : 'Cantidad Días'}
|
||||||
<input
|
</label>
|
||||||
type="number"
|
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
|
||||||
className="w-full text-center font-black text-slate-700 text-sm outline-none"
|
<button
|
||||||
value={days} onChange={e => setDays(parseInt(e.target.value) || 1)}
|
onClick={() => setDays(Math.max(product?.priceDurationDays || 1, days - (product?.priceDurationDays || 1)))}
|
||||||
/>
|
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
|
||||||
<button onClick={() => setDays(days + 1)} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">+</button>
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full text-center font-black text-slate-700 text-sm outline-none"
|
||||||
|
value={days}
|
||||||
|
onChange={e => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setDays(days + (product?.priceDurationDays || 1))}
|
||||||
|
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{product && product.priceDurationDays > 1 && (
|
||||||
|
<p className="text-[9px] text-blue-400 font-bold mt-1 ml-1 uppercase">Mínimo: {product.priceDurationDays} días</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="col-span-1 flex flex-col justify-center items-end">
|
<div className="col-span-1 flex flex-col justify-center items-end">
|
||||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
|
||||||
<div className="text-2xl font-mono font-black text-slate-900">
|
<div className="text-2xl font-mono font-black text-slate-900">
|
||||||
|
|||||||
@@ -231,14 +231,22 @@ export default function FastEntryPage() {
|
|||||||
productService.getByCategory(parseInt(formData.categoryId))
|
productService.getByCategory(parseInt(formData.categoryId))
|
||||||
.then(prods => {
|
.then(prods => {
|
||||||
setCategoryProducts(prods);
|
setCategoryProducts(prods);
|
||||||
// Auto-seleccionar el primero si solo hay uno
|
if (prods.length > 0) setSelectedProduct(prods[0]);
|
||||||
if (prods.length === 1) setSelectedProduct(prods[0]);
|
|
||||||
else setSelectedProduct(null);
|
else setSelectedProduct(null);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoadingProducts(false));
|
.finally(() => setLoadingProducts(false));
|
||||||
}, [formData.categoryId]);
|
}, [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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
setShowPaymentModal(true);
|
setShowPaymentModal(true);
|
||||||
@@ -566,14 +574,38 @@ export default function FastEntryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
{selectedProduct?.productTypeId !== 4 && selectedProduct?.productTypeId !== 6 && (
|
||||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label>
|
<div className="col-span-2">
|
||||||
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
|
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">
|
||||||
<button onClick={() => setFormData(f => ({ ...f, days: Math.max(1, f.days - 1) }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">-</button>
|
{selectedProduct && selectedProduct.priceDurationDays > 1 ? `Días (Base ${selectedProduct.priceDurationDays})` : 'Días'}
|
||||||
<input type="number" className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm" value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
|
</label>
|
||||||
<button onClick={() => setFormData(f => ({ ...f, days: f.days + 1 }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">+</button>
|
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
|
||||||
|
<button
|
||||||
|
onClick={() => setFormData(f => ({ ...f, days: Math.max(selectedProduct?.priceDurationDays || 1, f.days - (selectedProduct?.priceDurationDays || 1)) }))}
|
||||||
|
className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm"
|
||||||
|
value={formData.days}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value) || 0;
|
||||||
|
const step = selectedProduct?.priceDurationDays || 1;
|
||||||
|
setFormData({ ...formData, days: Math.max(step, val) });
|
||||||
|
}}
|
||||||
|
step={selectedProduct?.priceDurationDays || 1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setFormData(f => ({ ...f, days: f.days + (selectedProduct?.priceDurationDays || 1) }))}
|
||||||
|
className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="col-span-5 relative" ref={clientWrapperRef}>
|
<div className="col-span-5 relative" ref={clientWrapperRef}>
|
||||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
|
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
|
||||||
<div className="relative h-9">
|
<div className="relative h-9">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Product {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
priceDurationDays: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ public class ProductsController : ControllerBase
|
|||||||
{
|
{
|
||||||
return BadRequest(new { message = ex.Message });
|
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
|
// Helper: Obtener lista de empresas para llenar el combo en el Frontend
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class Product
|
|||||||
public string? ExternalId { get; set; }
|
public string? ExternalId { get; set; }
|
||||||
|
|
||||||
public decimal BasePrice { 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 decimal TaxRate { get; set; } // 21.0, 10.5, etc.
|
||||||
public string Currency { get; set; } = "ARS";
|
public string Currency { get; set; } = "ARS";
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ END
|
|||||||
ALTER TABLE Products ADD CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId) REFERENCES Categories(Id);
|
ALTER TABLE Products ADD CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId) REFERENCES Categories(Id);
|
||||||
END
|
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
|
-- CategoryPricing Columns
|
||||||
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
|
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ public class ProductRepository : IProductRepository
|
|||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
var sql = @"
|
var sql = @"
|
||||||
INSERT INTO Products (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, @TaxRate, @IsActive);
|
VALUES (@CompanyId, @ProductTypeId, @CategoryId, @Name, @Description, @SKU, @BasePrice, @PriceDurationDays, @TaxRate, @IsActive);
|
||||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
return await conn.QuerySingleAsync<int>(sql, product);
|
return await conn.QuerySingleAsync<int>(sql, product);
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ public class ProductRepository : IProductRepository
|
|||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
var sql = @"
|
var sql = @"
|
||||||
UPDATE Products
|
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";
|
WHERE Id = @Id";
|
||||||
await conn.ExecuteAsync(sql, product);
|
await conn.ExecuteAsync(sql, product);
|
||||||
}
|
}
|
||||||
@@ -191,9 +191,15 @@ public class ProductRepository : IProductRepository
|
|||||||
public async Task DeleteAsync(int id)
|
public async Task DeleteAsync(int id)
|
||||||
{
|
{
|
||||||
using var conn = _db.CreateConnection();
|
using var conn = _db.CreateConnection();
|
||||||
|
|
||||||
// 1. Verificar si está siendo usado como componente de un combo
|
// 1. Verificar si está siendo usado como componente de un combo
|
||||||
var usedInBundle = await conn.ExecuteScalarAsync<int>(
|
// Usamos IF OBJECT_ID para que sea robusto si la tabla no existe por algún motivo
|
||||||
"SELECT COUNT(1) FROM ProductBundles WHERE ChildProductId = @Id", new { Id = id });
|
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<int>(checkBundleSql, new { Id = id });
|
||||||
|
|
||||||
if (usedInBundle > 0)
|
if (usedInBundle > 0)
|
||||||
{
|
{
|
||||||
@@ -201,28 +207,31 @@ public class ProductRepository : IProductRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verificar si tiene registros asociados (ej: Listings)
|
// 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
|
// Usamos SQL dinámico con sp_executesql para evitar errores de compilación si la columna no existe
|
||||||
var hasSales = await conn.ExecuteScalarAsync<int>(@"
|
var checkSalesSql = @"
|
||||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = Object_ID('Listings'))
|
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = OBJECT_ID('Listings'))
|
||||||
BEGIN
|
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
|
END
|
||||||
ELSE
|
ELSE
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT 0
|
SELECT 0
|
||||||
END", new { Id = id });
|
END";
|
||||||
|
|
||||||
|
var hasSales = await conn.ExecuteScalarAsync<int>(checkSalesSql, new { Id = id });
|
||||||
|
|
||||||
if (hasSales > 0)
|
if (hasSales > 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("No se puede eliminar el producto porque ya tiene ventas o registros asociados.");
|
throw new InvalidOperationException("No se puede eliminar el producto porque ya tiene ventas o registros asociados.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Eliminar registros permitidos
|
// 3. Eliminar registros permitidos (Precios e hijos de bundle si es un combo)
|
||||||
// Borramos precios históricos y relaciones de bundle (si es el padre)
|
await conn.ExecuteAsync(@"
|
||||||
await conn.ExecuteAsync("DELETE FROM ProductPrices WHERE ProductId = @Id", new { Id = id });
|
IF OBJECT_ID('ProductPrices') IS NOT NULL DELETE FROM ProductPrices WHERE ProductId = @Id;
|
||||||
await conn.ExecuteAsync("DELETE FROM ProductBundles WHERE ParentProductId = @Id", new { Id = 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 });
|
await conn.ExecuteAsync("DELETE FROM Products WHERE Id = @Id", new { Id = id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,17 +33,23 @@ public class PricingService
|
|||||||
TotalPrice = 0,
|
TotalPrice = 0,
|
||||||
Details = "Producto no encontrado."
|
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
|
// 1. Obtener Reglas
|
||||||
var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId);
|
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
|
if (pricing == null) return new CalculatePriceResponse
|
||||||
{
|
{
|
||||||
TotalPrice = 0,
|
TotalPrice = productBasePrice * request.Days,
|
||||||
Details = "No hay tarifas configuradas para este rubro."
|
BaseCost = productBasePrice * request.Days,
|
||||||
|
Details = "El rubro no tiene tarifas configuradas. Se aplica solo el precio base del producto."
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Análisis del Texto
|
// 2. Análisis del Texto
|
||||||
|
|||||||
Reference in New Issue
Block a user