diff --git a/src/SIGCM.API/Controllers/AdvertisingController.cs b/src/SIGCM.API/Controllers/AdvertisingController.cs new file mode 100644 index 0000000..c84d923 --- /dev/null +++ b/src/SIGCM.API/Controllers/AdvertisingController.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AdvertisingController : ControllerBase +{ + private readonly IAdvertisingRepository _repo; + + public AdvertisingController(IAdvertisingRepository repo) + { + _repo = repo; + } + + // --- ENDPOINTS DE CONFIGURACIÓN (ADMIN) --- + + [HttpPost("grids")] + [Authorize(Roles = "Admin")] + public async Task CreateGrid(GraphicGrid grid) + { + var id = await _repo.CreateGridAsync(grid); + return Ok(new { id }); + } + + [HttpGet("company/{companyId}/grids")] + public async Task GetGrids(int companyId) + { + var grids = await _repo.GetGridsByCompanyAsync(companyId); + return Ok(grids); + } + + [HttpPost("radio-tariffs")] + [Authorize(Roles = "Admin")] + public async Task CreateRadioTariff(RadioTariff tariff) + { + var id = await _repo.CreateRadioTariffAsync(tariff); + return Ok(new { id }); + } + + // --- ENDPOINTS DE CÁLCULO (CALCULADORA) --- + + [HttpPost("calculate/graphic")] + public async Task CalculateGraphic([FromBody] GraphicCalcRequest request) + { + var grid = await _repo.GetGridByIdAsync(request.GridId); + if (grid == null) return NotFound("Tarifario no encontrado"); + + // Cálculo Base: (Columnas * Módulos) * PrecioUnitario + decimal basePrice = (request.Columns * request.Modules) * grid.PricePerModule; + + // Recargo Color + decimal colorSurcharge = request.IsColor + ? basePrice * (grid.ColorSurchargePercentage / 100m) + : 0; + + decimal total = basePrice + colorSurcharge; + + return Ok(new + { + Total = total, + Details = $"{request.Columns} col x {request.Modules} mod @ ${grid.PricePerModule}/mod" + (request.IsColor ? " + Color" : "") + }); + } +} + +// DTOs simples para la calculadora +public class GraphicCalcRequest +{ + public int GridId { get; set; } + public int Columns { get; set; } + public int Modules { get; set; } + public bool IsColor { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.API/Controllers/FinanceController.cs b/src/SIGCM.API/Controllers/FinanceController.cs new file mode 100644 index 0000000..e887e01 --- /dev/null +++ b/src/SIGCM.API/Controllers/FinanceController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize(Roles = "Admin,Gerente")] // Solo roles altos tocan dinero +public class FinanceController : ControllerBase +{ + private readonly IClientProfileRepository _repo; + + public FinanceController(IClientProfileRepository repo) + { + _repo = repo; + } + + [HttpGet("client/{id}")] + public async Task GetClientStatus(int id) + { + var profile = await _repo.GetProfileAsync(id); + var debt = await _repo.CalculateCurrentDebtAsync(id); + + if (profile == null) return NotFound("Cliente sin perfil financiero"); + + profile.CurrentDebt = debt; // Llenamos la propiedad calculada + return Ok(profile); + } + + [HttpPost("client/{id}")] + public async Task UpdateProfile(int id, [FromBody] ClientProfile profile) + { + profile.UserId = id; + await _repo.UpsertProfileAsync(profile); + return Ok(new { message = "Perfil financiero actualizado" }); + } + + [HttpGet("debtors")] + public async Task GetDebtors() + { + var debtors = await _repo.GetDebtorsAsync(); + return Ok(debtors); + } +} \ No newline at end of file diff --git a/src/SIGCM.API/Controllers/OrdersController.cs b/src/SIGCM.API/Controllers/OrdersController.cs new file mode 100644 index 0000000..de34a09 --- /dev/null +++ b/src/SIGCM.API/Controllers/OrdersController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM.Application.DTOs; +using SIGCM.Application.Interfaces; +using SIGCM.Domain.Interfaces; // Para acceder a repos de lectura directa si hace falta + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class OrdersController : ControllerBase +{ + private readonly IOrderService _orderService; + private readonly IOrderRepository _orderRepo; // Para lecturas simples (GET) + + public OrdersController(IOrderService orderService, IOrderRepository orderRepo) + { + _orderService = orderService; + _orderRepo = orderRepo; + } + + // Crear una nueva orden de venta + [HttpPost] + public async Task Create(CreateOrderDto dto) + { + try + { + // Seguridad: Forzar que el vendedor sea el usuario logueado si no se especifica + var userIdClaim = User.FindFirst("Id")?.Value; + if (int.TryParse(userIdClaim, out int userId)) + { + dto.SellerId = userId; + } + + var result = await _orderService.CreateOrderAsync(dto); + return Ok(result); + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + // Obtener historial de órdenes de un cliente + [HttpGet("client/{clientId}")] + public async Task GetByClient(int clientId) + { + var orders = await _orderRepo.GetByClientIdAsync(clientId); + return Ok(orders); + } + + // Obtener detalle de una orden + [HttpGet("{id}")] + public async Task GetById(int id) + { + var order = await _orderRepo.GetByIdAsync(id); + if (order == null) return NotFound(); + + var items = await _orderRepo.GetItemsByOrderIdAsync(id); + + return Ok(new { Order = order, Items = items }); + } +} \ No newline at end of file diff --git a/src/SIGCM.API/Controllers/ProductsController.cs b/src/SIGCM.API/Controllers/ProductsController.cs new file mode 100644 index 0000000..a2fa5ac --- /dev/null +++ b/src/SIGCM.API/Controllers/ProductsController.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] // Requiere login para ver el catálogo interno +public class ProductsController : ControllerBase +{ + private readonly IProductRepository _repository; + + public ProductsController(IProductRepository repository) + { + _repository = repository; + } + + [HttpGet] + public async Task GetAll() + { + var products = await _repository.GetAllAsync(); + return Ok(products); + } + + [HttpGet("{id}")] + public async Task GetById(int id) + { + var product = await _repository.GetByIdAsync(id); + if (product == null) return NotFound(); + return Ok(product); + } + + // Obtener productos de una empresa específica (ej: Solo productos de Radio) + [HttpGet("company/{companyId}")] + public async Task GetByCompany(int companyId) + { + var products = await _repository.GetByCompanyIdAsync(companyId); + return Ok(products); + } + + [HttpPost] + [Authorize(Roles = "Admin")] // Solo Admins crean productos + public async Task Create(Product product) + { + // Validaciones básicas + if (product.CompanyId <= 0) return BadRequest("Debe especificar una Empresa válida."); + if (product.ProductTypeId <= 0) return BadRequest("Debe especificar el Tipo de Producto."); + + var id = await _repository.CreateAsync(product); + product.Id = id; + return CreatedAtAction(nameof(GetById), new { id }, product); + } + + [HttpPut("{id}")] + [Authorize(Roles = "Admin")] + public async Task Update(int id, Product product) + { + if (id != product.Id) return BadRequest(); + await _repository.UpdateAsync(product); + return NoContent(); + } + + // Helper: Obtener lista de empresas para llenar el combo en el Frontend + [HttpGet("companies")] + public async Task GetCompanies() + { + var companies = await _repository.GetAllCompaniesAsync(); + return Ok(companies); + } + + // Agregar un producto hijo a un bundle + [HttpPost("{bundleId}/components")] + [Authorize(Roles = "Admin")] + public async Task AddBundleComponent(int bundleId, [FromBody] ProductBundle bundle) + { + // Validaciones de integridad + if (bundleId != bundle.ParentProductId && bundle.ParentProductId != 0) + return BadRequest("ID de URL no coincide con el cuerpo."); + + bundle.ParentProductId = bundleId; // Asegurar consistencia + + if (bundle.ChildProductId <= 0) return BadRequest("Debe especificar un producto hijo válido."); + if (bundle.Quantity <= 0) return BadRequest("La cantidad debe ser mayor a 0."); + + // Evitar ciclos (Un bundle no puede contenerse a sí mismo) + if (bundle.ParentProductId == bundle.ChildProductId) + return BadRequest("Un combo no puede contenerse a sí mismo."); + + await _repository.AddComponentToBundleAsync(bundle); + return Ok(new { message = "Componente configurado exitosamente." }); + } + + [HttpDelete("{bundleId}/components/{childId}")] + [Authorize(Roles = "Admin")] + public async Task RemoveBundleComponent(int bundleId, int childId) + { + await _repository.RemoveComponentFromBundleAsync(bundleId, childId); + return NoContent(); + } +} \ No newline at end of file diff --git a/src/SIGCM.API/Controllers/SellersController.cs b/src/SIGCM.API/Controllers/SellersController.cs new file mode 100644 index 0000000..28848cf --- /dev/null +++ b/src/SIGCM.API/Controllers/SellersController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class SellersController : ControllerBase +{ + private readonly ISellerRepository _repository; + + public SellersController(ISellerRepository repository) + { + _repository = repository; + } + + // Para llenar el combo de "Vendedor" en la pantalla de carga + [HttpGet] + public async Task GetAll() + { + var sellers = await _repository.GetAllActiveSellersAsync(); + return Ok(sellers); + } + + // Obtener configuración de un vendedor (Solo Admin) + [HttpGet("{id}/profile")] + [Authorize(Roles = "Admin")] + public async Task GetProfile(int id) + { + var profile = await _repository.GetProfileAsync(id); + // Si no tiene perfil aún, devolvemos un default vacío para que el front lo llene + if (profile == null) return Ok(new { UserId = id, BaseCommissionPercentage = 0 }); + return Ok(profile); + } + + // Guardar configuración (Solo Admin) + [HttpPost("{id}/profile")] + [Authorize(Roles = "Admin")] + public async Task UpsertProfile(int id, [FromBody] SellerProfile profile) + { + profile.UserId = id; // Asegurar ID + await _repository.UpsertProfileAsync(profile); + return Ok(new { message = "Perfil de vendedor actualizado" }); + } + + // Dashboard personal del vendedor (Mis Ventas del Mes) + [HttpGet("my-performance")] + public async Task GetMyPerformance() + { + var userIdStr = User.FindFirst("Id")?.Value; + if (!int.TryParse(userIdStr, out int userId)) return Unauthorized(); + + var totalSold = await _repository.GetMonthlySalesAsync(userId, DateTime.UtcNow); + + // Recuperamos su % para mostrar proyección estimada + var profile = await _repository.GetProfileAsync(userId); + var rate = profile?.BaseCommissionPercentage ?? 0; + + // Estimación gruesa (comisión sobre el total bruto, idealmente sería sobre el neto) + // Para mayor precisión se debería hacer un query de suma de CommissionAmount en OrderItems + var estimatedCommission = totalSold * (rate / 100m); + + return Ok(new + { + Period = DateTime.UtcNow.ToString("MM/yyyy"), + TotalSales = totalSold, + CommissionRate = rate, + EstimatedEarnings = estimatedCommission + }); + } +} \ No newline at end of file diff --git a/src/SIGCM.Application/DTOs/OrderDtos.cs b/src/SIGCM.Application/DTOs/OrderDtos.cs new file mode 100644 index 0000000..66d5a7d --- /dev/null +++ b/src/SIGCM.Application/DTOs/OrderDtos.cs @@ -0,0 +1,37 @@ +namespace SIGCM.Application.DTOs; + +public class CreateOrderDto +{ + public int ClientId { get; set; } // Quién compra + public int SellerId { get; set; } // Quién vende (Usuario logueado) + public List Items { get; set; } = new(); + + public DateTime? DueDate { get; set; } // Para Cta Cte + public string? Notes { get; set; } + + // Si es true, la orden nace como "Pagada" (Venta Contado) + // Si es false, nace como "Pendiente" (Cuenta Corriente) + public bool IsDirectPayment { get; set; } = true; +} + +public class OrderItemDto +{ + public int ProductId { get; set; } + public decimal Quantity { get; set; } + + // Opcional: Si el vendedor aplica un descuento manual unitario + // (Por ahora usaremos el precio base del producto) + // public decimal? ManualUnitPrice { get; set; } + + // Para vincular con un aviso específico creado previamente + public int? RelatedEntityId { get; set; } + public string? RelatedEntityType { get; set; } // 'Listing' +} + +public class OrderResultDto +{ + public int OrderId { get; set; } + public string OrderNumber { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public string Status { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/SIGCM.Application/Interfaces/IOrderService.cs b/src/SIGCM.Application/Interfaces/IOrderService.cs new file mode 100644 index 0000000..f8f6de9 --- /dev/null +++ b/src/SIGCM.Application/Interfaces/IOrderService.cs @@ -0,0 +1,12 @@ +using SIGCM.Application.DTOs; + +namespace SIGCM.Application.Interfaces; + +public interface IOrderService +{ + Task CreateOrderAsync(CreateOrderDto dto); + + // Aquí agregaremos métodos futuros como: + // Task CancelOrderAsync(int id); + // Task GenerateInvoicePdfAsync(int id); +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/ClientProfile.cs b/src/SIGCM.Domain/Entities/ClientProfile.cs new file mode 100644 index 0000000..9886643 --- /dev/null +++ b/src/SIGCM.Domain/Entities/ClientProfile.cs @@ -0,0 +1,15 @@ +namespace SIGCM.Domain.Entities; + +public class ClientProfile +{ + public int UserId { get; set; } + public decimal CreditLimit { get; set; } + public int PaymentTermsDays { get; set; } // Días de vencimiento por defecto + public bool IsCreditBlocked { get; set; } + public string? BlockReason { get; set; } + public DateTime LastCreditCheckAt { get; set; } + + // Propiedades calculadas (no se guardan en esta tabla, vienen del Join/Query) + public decimal CurrentDebt { get; set; } // Deuda actual calculada + public string? ClientName { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/Company.cs b/src/SIGCM.Domain/Entities/Company.cs new file mode 100644 index 0000000..99cf865 --- /dev/null +++ b/src/SIGCM.Domain/Entities/Company.cs @@ -0,0 +1,12 @@ +namespace SIGCM.Domain.Entities; + +public class Company +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string TaxId { get; set; } // CUIT + public string? LegalAddress { get; set; } + public string? LogoUrl { get; set; } + public string? ExternalSystemId { get; set; } + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/ExternalClientMapping.cs b/src/SIGCM.Domain/Entities/ExternalClientMapping.cs new file mode 100644 index 0000000..faf29e2 --- /dev/null +++ b/src/SIGCM.Domain/Entities/ExternalClientMapping.cs @@ -0,0 +1,10 @@ +namespace SIGCM.Domain.Entities; + +public class ExternalClientMapping +{ + public int LocalUserId { get; set; } + public required string ExternalSystemName { get; set; } // 'Tango', 'SAP', 'CustomBilling' + public required string ExternalClientId { get; set; } + public string? AdditionalData { get; set; } // JSON + public DateTime LastSyncedAt { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/GraphicGrid.cs b/src/SIGCM.Domain/Entities/GraphicGrid.cs new file mode 100644 index 0000000..44fce6f --- /dev/null +++ b/src/SIGCM.Domain/Entities/GraphicGrid.cs @@ -0,0 +1,20 @@ +namespace SIGCM.Domain.Entities; + +public class GraphicGrid +{ + public int Id { get; set; } + public int CompanyId { get; set; } + public required string Name { get; set; } + + public decimal ColumnWidthMm { get; set; } + public decimal ModuleHeightMm { get; set; } + + public decimal PricePerModule { get; set; } + public int MaxColumns { get; set; } + public int MaxModules { get; set; } + + public decimal ColorSurchargePercentage { get; set; } + public decimal TopPositionSurchargePercentage { get; set; } + + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/Order.cs b/src/SIGCM.Domain/Entities/Order.cs new file mode 100644 index 0000000..d4b4a00 --- /dev/null +++ b/src/SIGCM.Domain/Entities/Order.cs @@ -0,0 +1,26 @@ +namespace SIGCM.Domain.Entities; + +public class Order +{ + public int Id { get; set; } + public required string OrderNumber { get; set; } // Ej: ORD-2026-0001 + public int? ClientId { get; set; } // Usuario Comprador + public int? SellerId { get; set; } // Usuario Vendedor (si aplica) + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? DueDate { get; set; } // Vencimiento (Cta Cte) + + public decimal TotalNet { get; set; } + public decimal TotalTax { get; set; } + public decimal TotalAmount { get; set; } + + public string PaymentStatus { get; set; } = "Pending"; // Pending, Paid, Partial + public string FulfillmentStatus { get; set; } = "Pending"; // Pending, Fulfilled + + public string? ExternalBillingId { get; set; } // ID en sistema externo + public string? Notes { get; set; } + + // Auxiliares + public string? ClientName { get; set; } + public string? SellerName { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/OrderItem.cs b/src/SIGCM.Domain/Entities/OrderItem.cs new file mode 100644 index 0000000..f49b7d3 --- /dev/null +++ b/src/SIGCM.Domain/Entities/OrderItem.cs @@ -0,0 +1,24 @@ +namespace SIGCM.Domain.Entities; + +public class OrderItem +{ + public int Id { get; set; } + public int OrderId { get; set; } + public int ProductId { get; set; } + public int CompanyId { get; set; } // Para facturación cruzada + + // Vinculación polimórfica (Ej: ID del Listing que se acaba de crear) + public int? RelatedEntityId { get; set; } + public string? RelatedEntityType { get; set; } // 'Listing', 'Merchandise', 'RadioSpot' + + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal TaxRate { get; set; } + public decimal SubTotal { get; set; } + + public decimal CommissionPercentage { get; set; } + public decimal CommissionAmount { get; set; } + + // Auxiliar + public string? ProductName { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/Product.cs b/src/SIGCM.Domain/Entities/Product.cs new file mode 100644 index 0000000..f774efb --- /dev/null +++ b/src/SIGCM.Domain/Entities/Product.cs @@ -0,0 +1,22 @@ +namespace SIGCM.Domain.Entities; + +public class Product +{ + public int Id { get; set; } + public int CompanyId { get; set; } + public int ProductTypeId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? SKU { get; set; } + public string? ExternalId { get; set; } + + public decimal BasePrice { get; set; } + public decimal TaxRate { get; set; } // 21.0, 10.5, etc. + public string Currency { get; set; } = "ARS"; + + public bool IsActive { get; set; } = true; + + // Propiedades auxiliares para Joins + public string? CompanyName { get; set; } + public string? TypeCode { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc. +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/ProductBundle.cs b/src/SIGCM.Domain/Entities/ProductBundle.cs new file mode 100644 index 0000000..4a34b07 --- /dev/null +++ b/src/SIGCM.Domain/Entities/ProductBundle.cs @@ -0,0 +1,13 @@ +namespace SIGCM.Domain.Entities; + +public class ProductBundle +{ + public int Id { get; set; } + public int ParentProductId { get; set; } + public int ChildProductId { get; set; } + public decimal Quantity { get; set; } + public decimal? FixedAllocationAmount { get; set; } + + // Propiedades de navegación (para lógica de negocio) + public Product? ChildProduct { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/RadioTariff.cs b/src/SIGCM.Domain/Entities/RadioTariff.cs new file mode 100644 index 0000000..be72e59 --- /dev/null +++ b/src/SIGCM.Domain/Entities/RadioTariff.cs @@ -0,0 +1,16 @@ +namespace SIGCM.Domain.Entities; + +public class RadioTariff +{ + public int Id { get; set; } + public int CompanyId { get; set; } + public required string ProgramName { get; set; } + + public TimeSpan? TimeSlotStart { get; set; } + public TimeSpan? TimeSlotEnd { get; set; } + + public decimal PricePerSecond { get; set; } + public decimal PricePerSpot { get; set; } + + public bool IsActive { get; set; } = true; +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Entities/SellerProfile.cs b/src/SIGCM.Domain/Entities/SellerProfile.cs new file mode 100644 index 0000000..3f66133 --- /dev/null +++ b/src/SIGCM.Domain/Entities/SellerProfile.cs @@ -0,0 +1,12 @@ +namespace SIGCM.Domain.Entities; + +public class SellerProfile +{ + public int UserId { get; set; } // PK y FK a Users + public string? SellerCode { get; set; } + public decimal BaseCommissionPercentage { get; set; } + public bool IsActive { get; set; } = true; + + // Auxiliar + public string? Username { get; set; } +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Interfaces/IAdvertisingRepository.cs b/src/SIGCM.Domain/Interfaces/IAdvertisingRepository.cs new file mode 100644 index 0000000..d9890a6 --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/IAdvertisingRepository.cs @@ -0,0 +1,15 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface IAdvertisingRepository +{ + // Gráfica + Task> GetGridsByCompanyAsync(int companyId); + Task GetGridByIdAsync(int id); + Task CreateGridAsync(GraphicGrid grid); + + // Radio + Task> GetRadioTariffsByCompanyAsync(int companyId); + Task CreateRadioTariffAsync(RadioTariff tariff); +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Interfaces/IClientProfileRepository.cs b/src/SIGCM.Domain/Interfaces/IClientProfileRepository.cs new file mode 100644 index 0000000..6e13c2f --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/IClientProfileRepository.cs @@ -0,0 +1,15 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface IClientProfileRepository +{ + Task GetProfileAsync(int userId); + Task UpsertProfileAsync(ClientProfile profile); + + // La métrica crítica: Suma de (TotalAmount - Pagado) de órdenes pendientes + Task CalculateCurrentDebtAsync(int userId); + + // Listado de clientes con deuda (Reporte de Morosos) + Task> GetDebtorsAsync(); +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Interfaces/IOrderRepository.cs b/src/SIGCM.Domain/Interfaces/IOrderRepository.cs new file mode 100644 index 0000000..cf69e6f --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/IOrderRepository.cs @@ -0,0 +1,16 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface IOrderRepository +{ + // Crear una orden completa con sus ítems transaccionalmente + Task CreateOrderAsync(Order order, IEnumerable items); + + Task GetByIdAsync(int id); + Task> GetByClientIdAsync(int clientId); + Task> GetItemsByOrderIdAsync(int orderId); + + // Gestión de Estados (Cta Cte -> Pagado) + Task UpdatePaymentStatusAsync(int orderId, string status); +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Interfaces/IProductRepository.cs b/src/SIGCM.Domain/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..c4b57d6 --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/IProductRepository.cs @@ -0,0 +1,16 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface IProductRepository +{ + Task> GetAllAsync(); + Task> GetByCompanyIdAsync(int companyId); + Task GetByIdAsync(int id); + Task CreateAsync(Product product); + Task UpdateAsync(Product product); + Task> GetAllCompaniesAsync(); // Helper para no crear un repo solo para esto por ahora + Task> GetBundleComponentsAsync(int parentProductId); + Task AddComponentToBundleAsync(ProductBundle bundle); + Task RemoveComponentFromBundleAsync(int bundleId, int childProductId); +} \ No newline at end of file diff --git a/src/SIGCM.Domain/Interfaces/ISellerRepository.cs b/src/SIGCM.Domain/Interfaces/ISellerRepository.cs new file mode 100644 index 0000000..ad8c451 --- /dev/null +++ b/src/SIGCM.Domain/Interfaces/ISellerRepository.cs @@ -0,0 +1,18 @@ +using SIGCM.Domain.Entities; + +namespace SIGCM.Domain.Interfaces; + +public interface ISellerRepository +{ + // Obtener perfil específico (con datos de usuario) + Task GetProfileAsync(int userId); + + // Guardar o actualizar configuración del vendedor + Task UpsertProfileAsync(SellerProfile profile); + + // Listar todos los usuarios que son vendedores activos + Task> GetAllActiveSellersAsync(); + + // Obtener métricas rápidas (Total vendido en el mes) + Task GetMonthlySalesAsync(int sellerId, DateTime month); +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/DependencyInjection.cs b/src/SIGCM.Infrastructure/DependencyInjection.cs index 30911c7..ae9b128 100644 --- a/src/SIGCM.Infrastructure/DependencyInjection.cs +++ b/src/SIGCM.Infrastructure/DependencyInjection.cs @@ -27,13 +27,19 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); //services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars) services.AddScoped(sp => diff --git a/src/SIGCM.Infrastructure/Repositories/AdvertisingRepository.cs b/src/SIGCM.Infrastructure/Repositories/AdvertisingRepository.cs new file mode 100644 index 0000000..06e5011 --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/AdvertisingRepository.cs @@ -0,0 +1,61 @@ +using Dapper; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class AdvertisingRepository : IAdvertisingRepository +{ + private readonly IDbConnectionFactory _db; + public AdvertisingRepository(IDbConnectionFactory db) => _db = db; + + // --- GRÁFICA --- + public async Task> GetGridsByCompanyAsync(int companyId) + { + using var conn = _db.CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM GraphicGrids WHERE CompanyId = @CompanyId AND IsActive = 1", + new { CompanyId = companyId }); + } + + public async Task GetGridByIdAsync(int id) + { + using var conn = _db.CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM GraphicGrids WHERE Id = @Id", new { Id = id }); + } + + public async Task CreateGridAsync(GraphicGrid grid) + { + using var conn = _db.CreateConnection(); + var sql = @" + INSERT INTO GraphicGrids + (CompanyId, Name, ColumnWidthMm, ModuleHeightMm, PricePerModule, MaxColumns, MaxModules, ColorSurchargePercentage, IsActive) + VALUES + (@CompanyId, @Name, @ColumnWidthMm, @ModuleHeightMm, @PricePerModule, @MaxColumns, @MaxModules, @ColorSurchargePercentage, @IsActive); + SELECT CAST(SCOPE_IDENTITY() as int);"; + return await conn.QuerySingleAsync(sql, grid); + } + + // --- RADIO --- + public async Task> GetRadioTariffsByCompanyAsync(int companyId) + { + using var conn = _db.CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM RadioTariffs WHERE CompanyId = @CompanyId AND IsActive = 1", + new { CompanyId = companyId }); + } + + public async Task CreateRadioTariffAsync(RadioTariff tariff) + { + using var conn = _db.CreateConnection(); + var sql = @" + INSERT INTO RadioTariffs + (CompanyId, ProgramName, TimeSlotStart, TimeSlotEnd, PricePerSecond, PricePerSpot, IsActive) + VALUES + (@CompanyId, @ProgramName, @TimeSlotStart, @TimeSlotEnd, @PricePerSecond, @PricePerSpot, @IsActive); + SELECT CAST(SCOPE_IDENTITY() as int);"; + return await conn.QuerySingleAsync(sql, tariff); + } +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Repositories/ClientProfileRepository.cs b/src/SIGCM.Infrastructure/Repositories/ClientProfileRepository.cs new file mode 100644 index 0000000..097052b --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/ClientProfileRepository.cs @@ -0,0 +1,103 @@ +using Dapper; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class ClientProfileRepository : IClientProfileRepository +{ + private readonly IDbConnectionFactory _db; + public ClientProfileRepository(IDbConnectionFactory db) => _db = db; + + public async Task GetProfileAsync(int userId) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT cp.*, u.BillingName as ClientName + FROM ClientProfiles cp + JOIN Users u ON cp.UserId = u.Id + WHERE cp.UserId = @UserId"; + return await conn.QuerySingleOrDefaultAsync(sql, new { UserId = userId }); + } + + public async Task UpsertProfileAsync(ClientProfile profile) + { + using var conn = _db.CreateConnection(); + var exists = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM ClientProfiles WHERE UserId = @UserId", new { profile.UserId }); + + if (exists > 0) + { + var sql = @" + UPDATE ClientProfiles + SET CreditLimit = @CreditLimit, + PaymentTermsDays = @PaymentTermsDays, + IsCreditBlocked = @IsCreditBlocked, + BlockReason = @BlockReason, + LastCreditCheckAt = GETUTCDATE() + WHERE UserId = @UserId"; + await conn.ExecuteAsync(sql, profile); + } + else + { + var sql = @" + INSERT INTO ClientProfiles (UserId, CreditLimit, PaymentTermsDays, IsCreditBlocked, BlockReason) + VALUES (@UserId, @CreditLimit, @PaymentTermsDays, @IsCreditBlocked, @BlockReason)"; + await conn.ExecuteAsync(sql, profile); + } + } + + public async Task CalculateCurrentDebtAsync(int userId) + { + using var conn = _db.CreateConnection(); + // Sumamos el total de órdenes que NO están pagadas ('Paid') ni canceladas ('Cancelled') + // Asumimos deuda total por simplicidad. En un sistema más complejo sumaríamos (TotalAmount - PagosParciales). + var sql = @" + SELECT ISNULL(SUM(TotalAmount), 0) + FROM Orders + WHERE ClientId = @UserId + AND PaymentStatus NOT IN ('Paid', 'Cancelled')"; + + return await conn.ExecuteScalarAsync(sql, new { UserId = userId }); + } + + public async Task> GetDebtorsAsync() + { + using var conn = _db.CreateConnection(); + + // ESTRATEGIA OPTIMIZADA: + // 1. Hacemos JOIN de Perfiles, Usuarios y Órdenes. + // 2. Filtramos solo órdenes impagas. + // 3. Agrupamos por Cliente. + // 4. Filtramos (HAVING) solo aquellos cuya suma de deuda sea > 0. + // 5. Esto se ejecuta en el motor de base de datos, no en memoria. + + var sql = @" + SELECT + cp.UserId, + cp.CreditLimit, + cp.PaymentTermsDays, + cp.IsCreditBlocked, + cp.BlockReason, + cp.LastCreditCheckAt, + u.BillingName as ClientName, + SUM(o.TotalAmount) as CurrentDebt + FROM ClientProfiles cp + INNER JOIN Users u ON cp.UserId = u.Id + INNER JOIN Orders o ON o.ClientId = cp.UserId + WHERE o.PaymentStatus NOT IN ('Paid', 'Cancelled') -- Solo deuda activa + GROUP BY + cp.UserId, + cp.CreditLimit, + cp.PaymentTermsDays, + cp.IsCreditBlocked, + cp.BlockReason, + cp.LastCreditCheckAt, + u.BillingName + HAVING SUM(o.TotalAmount) > 0 + ORDER BY CurrentDebt DESC"; + + return await conn.QueryAsync(sql); + } +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Repositories/OrderRepository.cs b/src/SIGCM.Infrastructure/Repositories/OrderRepository.cs new file mode 100644 index 0000000..61007e9 --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/OrderRepository.cs @@ -0,0 +1,101 @@ +using Dapper; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class OrderRepository : IOrderRepository +{ + private readonly IDbConnectionFactory _db; + + public OrderRepository(IDbConnectionFactory db) => _db = db; + + public async Task CreateOrderAsync(Order order, IEnumerable items) + { + using var conn = _db.CreateConnection(); + conn.Open(); + using var transaction = conn.BeginTransaction(); + + try + { + // 1. Obtener siguiente número de secuencia de forma atómica + // Formato: ORD-{AÑO}-{SECUENCIA} (Ej: ORD-2026-000015) + var nextVal = await conn.ExecuteScalarAsync( + "SELECT NEXT VALUE FOR OrderNumberSeq", transaction: transaction); + + var orderNumber = $"ORD-{DateTime.UtcNow.Year}-{nextVal:D6}"; + order.OrderNumber = orderNumber; + + // 2. Insertar Cabecera + var sqlOrder = @" + INSERT INTO Orders + (OrderNumber, ClientId, SellerId, CreatedAt, DueDate, TotalNet, TotalTax, TotalAmount, PaymentStatus, FulfillmentStatus, Notes) + VALUES + (@OrderNumber, @ClientId, @SellerId, GETUTCDATE(), @DueDate, @TotalNet, @TotalTax, @TotalAmount, @PaymentStatus, @FulfillmentStatus, @Notes); + SELECT CAST(SCOPE_IDENTITY() as int);"; + + var orderId = await conn.QuerySingleAsync(sqlOrder, order, transaction); + + // 3. Insertar Items (Código existente...) + var sqlItem = @" + INSERT INTO OrderItems + (OrderId, ProductId, CompanyId, RelatedEntityId, RelatedEntityType, Quantity, UnitPrice, TaxRate, SubTotal, CommissionPercentage, CommissionAmount) + VALUES + (@OrderId, @ProductId, @CompanyId, @RelatedEntityId, @RelatedEntityType, @Quantity, @UnitPrice, @TaxRate, @SubTotal, @CommissionPercentage, @CommissionAmount)"; + + foreach (var item in items) + { + item.OrderId = orderId; + await conn.ExecuteAsync(sqlItem, item, transaction); + } + + transaction.Commit(); + return orderId; + } + catch + { + transaction.Rollback(); + throw; + } + } + + public async Task GetByIdAsync(int id) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT o.*, u.Username as SellerName, c.BillingName as ClientName + FROM Orders o + LEFT JOIN Users u ON o.SellerId = u.Id + LEFT JOIN Users c ON o.ClientId = c.Id + WHERE o.Id = @Id"; + return await conn.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByClientIdAsync(int clientId) + { + using var conn = _db.CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM Orders WHERE ClientId = @ClientId ORDER BY CreatedAt DESC", + new { ClientId = clientId }); + } + + public async Task> GetItemsByOrderIdAsync(int orderId) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT oi.*, p.Name as ProductName + FROM OrderItems oi + JOIN Products p ON oi.ProductId = p.Id + WHERE oi.OrderId = @OrderId"; + return await conn.QueryAsync(sql, new { OrderId = orderId }); + } + + public async Task UpdatePaymentStatusAsync(int orderId, string status) + { + using var conn = _db.CreateConnection(); + await conn.ExecuteAsync( + "UPDATE Orders SET PaymentStatus = @Status WHERE Id = @Id", + new { Id = orderId, Status = status }); + } +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs new file mode 100644 index 0000000..54482dd --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs @@ -0,0 +1,129 @@ +using Dapper; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class ProductRepository : IProductRepository +{ + private readonly IDbConnectionFactory _db; + + public ProductRepository(IDbConnectionFactory db) => _db = db; + + public async Task> GetAllAsync() + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode + FROM Products p + JOIN Companies c ON p.CompanyId = c.Id + JOIN ProductTypes pt ON p.ProductTypeId = pt.Id + WHERE p.IsActive = 1"; + return await conn.QueryAsync(sql); + } + + public async Task GetByIdAsync(int id) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode + FROM Products p + JOIN Companies c ON p.CompanyId = c.Id + JOIN ProductTypes pt ON p.ProductTypeId = pt.Id + WHERE p.Id = @Id"; + return await conn.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByCompanyIdAsync(int companyId) + { + using var conn = _db.CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM Products WHERE CompanyId = @Id AND IsActive = 1", + new { Id = companyId }); + } + + public async Task CreateAsync(Product product) + { + using var conn = _db.CreateConnection(); + var sql = @" + INSERT INTO Products (CompanyId, ProductTypeId, Name, Description, SKU, BasePrice, TaxRate, IsActive) + VALUES (@CompanyId, @ProductTypeId, @Name, @Description, @SKU, @BasePrice, @TaxRate, @IsActive); + SELECT CAST(SCOPE_IDENTITY() as int);"; + return await conn.QuerySingleAsync(sql, product); + } + + public async Task UpdateAsync(Product product) + { + using var conn = _db.CreateConnection(); + var sql = @" + UPDATE Products + SET Name = @Name, Description = @Description, BasePrice = @BasePrice, TaxRate = @TaxRate, IsActive = @IsActive + WHERE Id = @Id"; + await conn.ExecuteAsync(sql, product); + } + + public async Task> GetAllCompaniesAsync() + { + using var conn = _db.CreateConnection(); + return await conn.QueryAsync("SELECT * FROM Companies WHERE IsActive = 1"); + } + + public async Task> GetBundleComponentsAsync(int parentProductId) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT pb.*, p.* + FROM ProductBundles pb + JOIN Products p ON pb.ChildProductId = p.Id + WHERE pb.ParentProductId = @ParentProductId"; + + // Usamos Dapper Multi-Mapping para llenar el objeto ChildProduct + return await conn.QueryAsync( + sql, + (bundle, product) => + { + bundle.ChildProduct = product; + return bundle; + }, + new { ParentProductId = parentProductId }, + splitOn: "Id" // Asumiendo que p.Id es la columna donde empieza el split + ); + } + + public async Task AddComponentToBundleAsync(ProductBundle bundle) + { + using var conn = _db.CreateConnection(); + // 1. Validar que no exista ya esa relación para evitar duplicados + var exists = await conn.ExecuteScalarAsync( + @"SELECT COUNT(1) FROM ProductBundles + WHERE ParentProductId = @ParentProductId AND ChildProductId = @ChildProductId", + bundle); + + if (exists > 0) + { + // En producción, actualizamos la cantidad en lugar de fallar o duplicar + var updateSql = @" + UPDATE ProductBundles + SET Quantity = @Quantity, FixedAllocationAmount = @FixedAllocationAmount + WHERE ParentProductId = @ParentProductId AND ChildProductId = @ChildProductId"; + await conn.ExecuteAsync(updateSql, bundle); + } + else + { + // Insertar nueva relación + var insertSql = @" + INSERT INTO ProductBundles (ParentProductId, ChildProductId, Quantity, FixedAllocationAmount) + VALUES (@ParentProductId, @ChildProductId, @Quantity, @FixedAllocationAmount)"; + await conn.ExecuteAsync(insertSql, bundle); + } + } + + public async Task RemoveComponentFromBundleAsync(int bundleId, int childProductId) + { + using var conn = _db.CreateConnection(); + await conn.ExecuteAsync( + "DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId", + new { ParentId = bundleId, ChildId = childProductId }); + } +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Repositories/SellerRepository.cs b/src/SIGCM.Infrastructure/Repositories/SellerRepository.cs new file mode 100644 index 0000000..9f4d671 --- /dev/null +++ b/src/SIGCM.Infrastructure/Repositories/SellerRepository.cs @@ -0,0 +1,78 @@ +using Dapper; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; +using SIGCM.Infrastructure.Data; + +namespace SIGCM.Infrastructure.Repositories; + +public class SellerRepository : ISellerRepository +{ + private readonly IDbConnectionFactory _db; + public SellerRepository(IDbConnectionFactory db) => _db = db; + + public async Task GetProfileAsync(int userId) + { + using var conn = _db.CreateConnection(); + var sql = @" + SELECT sp.*, u.Username + FROM SellerProfiles sp + JOIN Users u ON sp.UserId = u.Id + WHERE sp.UserId = @UserId"; + return await conn.QuerySingleOrDefaultAsync(sql, new { UserId = userId }); + } + + public async Task UpsertProfileAsync(SellerProfile profile) + { + using var conn = _db.CreateConnection(); + var exists = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM SellerProfiles WHERE UserId = @UserId", new { profile.UserId }); + + if (exists > 0) + { + var sql = @" + UPDATE SellerProfiles + SET SellerCode = @SellerCode, + BaseCommissionPercentage = @BaseCommissionPercentage, + IsActive = @IsActive + WHERE UserId = @UserId"; + await conn.ExecuteAsync(sql, profile); + } + else + { + var sql = @" + INSERT INTO SellerProfiles (UserId, SellerCode, BaseCommissionPercentage, IsActive) + VALUES (@UserId, @SellerCode, @BaseCommissionPercentage, @IsActive)"; + await conn.ExecuteAsync(sql, profile); + } + } + + public async Task> GetAllActiveSellersAsync() + { + using var conn = _db.CreateConnection(); + // Traemos usuarios que tienen perfil de vendedor O que tienen rol de Vendedor/Cajero + var sql = @" + SELECT u.Id, u.Username, sp.SellerCode, ISNULL(sp.BaseCommissionPercentage, 0) as CommissionRate + FROM Users u + LEFT JOIN SellerProfiles sp ON u.Id = sp.UserId + WHERE u.IsActive = 1 + AND (sp.IsActive = 1 OR u.Role IN ('Vendedor', 'Cajero')) + ORDER BY u.Username"; + return await conn.QueryAsync(sql); + } + + public async Task GetMonthlySalesAsync(int sellerId, DateTime date) + { + using var conn = _db.CreateConnection(); + var start = new DateTime(date.Year, date.Month, 1); + var end = start.AddMonths(1).AddSeconds(-1); + + var sql = @" + SELECT ISNULL(SUM(TotalAmount), 0) + FROM Orders + WHERE SellerId = @SellerId + AND CreatedAt BETWEEN @Start AND @End + AND PaymentStatus = 'Paid'"; // Solo ventas cobradas suman para comisión (regla común) + + return await conn.ExecuteScalarAsync(sql, new { SellerId = sellerId, Start = start, End = end }); + } +} \ No newline at end of file diff --git a/src/SIGCM.Infrastructure/Services/OrderService.cs b/src/SIGCM.Infrastructure/Services/OrderService.cs new file mode 100644 index 0000000..f69e584 --- /dev/null +++ b/src/SIGCM.Infrastructure/Services/OrderService.cs @@ -0,0 +1,222 @@ +using SIGCM.Application.DTOs; +using SIGCM.Application.Interfaces; +using SIGCM.Domain.Entities; +using SIGCM.Domain.Interfaces; + +namespace SIGCM.Infrastructure.Services; + +public class OrderService : IOrderService +{ + private readonly IOrderRepository _orderRepo; + private readonly IProductRepository _productRepo; + private readonly ISellerRepository _sellerRepo; + private readonly IClientProfileRepository _clientRepo; + + public OrderService(IOrderRepository orderRepo, IProductRepository productRepo, ISellerRepository sellerRepo, IClientProfileRepository clientRepo) + { + _orderRepo = orderRepo; + _productRepo = productRepo; + _sellerRepo = sellerRepo; + _clientRepo = clientRepo; + } + + public async Task CreateOrderAsync(CreateOrderDto dto) + { + // 1. Preparar la cabecera + var order = new Order + { + OrderNumber = GenerateOrderNumber(), + ClientId = dto.ClientId, + SellerId = dto.SellerId, + CreatedAt = DateTime.UtcNow, + DueDate = dto.DueDate, + Notes = dto.Notes, + // Estado inicial: Si es pago directo -> 'Paid', sino 'Pending' + PaymentStatus = dto.IsDirectPayment ? "Paid" : "Pending", + FulfillmentStatus = "Pending" + }; + + // Obtener perfil del vendedor para saber su % de comisión + decimal sellerCommissionRate = 0; + var sellerProfile = await _sellerRepo.GetProfileAsync(dto.SellerId); + if (sellerProfile != null && sellerProfile.IsActive) + { + sellerCommissionRate = sellerProfile.BaseCommissionPercentage; + } + + // --- VALIDACIÓN DE CRÉDITO --- + if (!dto.IsDirectPayment) // Si es Cuenta Corriente + { + var profile = await _clientRepo.GetProfileAsync(dto.ClientId); + + // 1. Verificar si existe perfil financiero + if (profile == null) + { + // Opción A: Bloquear si no tiene perfil (Estricto) + // Opción B: Permitir si el sistema asume límite 0 o default (Flexible) + // Vamos con opción Estricta para obligar al alta administrativa + throw new InvalidOperationException("El cliente no tiene habilitada la Cuenta Corriente (Falta Perfil Financiero)."); + } + + // 2. Verificar bloqueo manual (Legales, Mora, etc) + if (profile.IsCreditBlocked) + { + throw new InvalidOperationException($"Crédito BLOQUEADO para este cliente. Motivo: {profile.BlockReason}"); + } + + // 3. Calcular Deuda Actual + Nueva Orden + // Nota: Necesitamos calcular el total de la orden actual ANTES de validar + // (Para no duplicar código, calculamos totales primero y validamos antes de guardar) + } + + var orderItems = new List(); + decimal totalNet = 0; + decimal totalTax = 0; + + // 2. Procesar ítems y calcular montos reales + foreach (var itemDto in dto.Items) + { + var product = await _productRepo.GetByIdAsync(itemDto.ProductId); + if (product == null) throw new Exception($"Producto {itemDto.ProductId} no encontrado."); + + // DETECCIÓN: ¿Es un Combo? + if (product.TypeCode == "BUNDLE") + { + // 1. Obtener componentes + var components = await _productRepo.GetBundleComponentsAsync(product.Id); + if (!components.Any()) throw new Exception($"El combo {product.Name} no tiene componentes definidos."); + + // 2. Calcular totales base para prorrateo + // El precio al que vendemos el combo (ej: $1000) + decimal bundleSellPrice = product.BasePrice; + + // La suma de los precios de lista de los componentes (ej: $600 + $600 = $1200) + decimal totalComponentsBasePrice = components.Sum(c => c.ChildProduct!.BasePrice * c.Quantity); + + if (totalComponentsBasePrice == 0) totalComponentsBasePrice = 1; // Evitar div/0 + + // 3. Iterar componentes y crear ítems individuales ("Explosión") + foreach (var comp in components) + { + var child = comp.ChildProduct!; + + // Lógica de Prorrateo: + // (PrecioBaseHijo / PrecioBaseTotalHijos) * PrecioVentaCombo + // Ej: (600 / 1200) * 1000 = $500 + decimal allocatedUnitPrice; + + if (comp.FixedAllocationAmount.HasValue) + { + allocatedUnitPrice = comp.FixedAllocationAmount.Value; + } + else + { + decimal ratio = child.BasePrice / totalComponentsBasePrice; + allocatedUnitPrice = bundleSellPrice * ratio; + } + + // Calcular línea para el componente + decimal qty = itemDto.Quantity * comp.Quantity; // Cantidad pedida * Cantidad en combo + decimal subTotal = allocatedUnitPrice * qty; + decimal taxAmount = subTotal * (child.TaxRate / 100m); + + totalNet += subTotal; + totalTax += taxAmount; + + orderItems.Add(new OrderItem + { + ProductId = child.Id, + CompanyId = child.CompanyId, + Quantity = qty, + UnitPrice = allocatedUnitPrice, + TaxRate = child.TaxRate, + SubTotal = subTotal + taxAmount, + RelatedEntityId = itemDto.RelatedEntityId, + RelatedEntityType = itemDto.RelatedEntityType, + ProductName = $"{product.Name} > {child.Name}" // Traza para saber que vino de un combo + // La comisión se calculará después si es necesario + }); + } + } + else + { + // --- LÓGICA ESTÁNDAR (PRODUCTO NORMAL) --- + decimal unitPrice = product.BasePrice; + decimal subTotalNet = unitPrice * itemDto.Quantity; + decimal taxAmount = subTotalNet * (product.TaxRate / 100m); + + totalNet += subTotalNet; + totalTax += taxAmount; + + // Cálculo Comisión Vendedor + decimal commissionAmt = subTotalNet * (sellerCommissionRate / 100m); + + orderItems.Add(new OrderItem + { + ProductId = product.Id, + CompanyId = product.CompanyId, + Quantity = itemDto.Quantity, + UnitPrice = unitPrice, + TaxRate = product.TaxRate, + SubTotal = subTotalNet + taxAmount, + RelatedEntityId = itemDto.RelatedEntityId, + RelatedEntityType = itemDto.RelatedEntityType, + CommissionPercentage = sellerCommissionRate, + CommissionAmount = commissionAmt, + ProductName = product.Name + }); + } + } + + // 3. Finalizar totales de cabecera + order.TotalNet = totalNet; + order.TotalTax = totalTax; + order.TotalAmount = totalNet + totalTax; + + // --- VALIDACIÓN DE LÍMITE (Post-Cálculo) --- + if (!dto.IsDirectPayment) + { + var profile = await _clientRepo.GetProfileAsync(dto.ClientId); + if (profile != null) + { + var currentDebt = await _clientRepo.CalculateCurrentDebtAsync(dto.ClientId); + var totalAmount = totalNet + totalTax; + var newTotalDebt = currentDebt + totalAmount; + + if (newTotalDebt > profile.CreditLimit) + { + // AQUÍ OCURRE EL "CORTE DE VENTAS" + throw new InvalidOperationException( + $"Límite de crédito excedido. " + + $"Límite: ${profile.CreditLimit:N2} | " + + $"Deuda Actual: ${currentDebt:N2} | " + + $"Nuevo Total: ${newTotalDebt:N2}"); + } + + // Si pasa, asignamos fecha de vencimiento automática si no vino en el DTO + if (!order.DueDate.HasValue) + { + order.DueDate = DateTime.UtcNow.AddDays(profile.PaymentTermsDays); + } + } + } + + // 4. Guardar en Base de Datos + var orderId = await _orderRepo.CreateOrderAsync(order, orderItems); + + return new OrderResultDto + { + OrderId = orderId, + OrderNumber = order.OrderNumber, + TotalAmount = order.TotalAmount, + Status = order.PaymentStatus + }; + } + + private string GenerateOrderNumber() + { + // Generador simple: ORD-AÑO-RANDOM + // En producción idealmente usaríamos una secuencia de SQL + return $"ORD-{DateTime.Now.Year}-{Guid.NewGuid().ToString().Substring(0, 8).ToUpper()}"; + } +} \ No newline at end of file