Feat Backend ERP 1
This commit is contained in:
78
src/SIGCM.API/Controllers/AdvertisingController.cs
Normal file
78
src/SIGCM.API/Controllers/AdvertisingController.cs
Normal file
@@ -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<IActionResult> CreateGrid(GraphicGrid grid)
|
||||||
|
{
|
||||||
|
var id = await _repo.CreateGridAsync(grid);
|
||||||
|
return Ok(new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("company/{companyId}/grids")]
|
||||||
|
public async Task<IActionResult> GetGrids(int companyId)
|
||||||
|
{
|
||||||
|
var grids = await _repo.GetGridsByCompanyAsync(companyId);
|
||||||
|
return Ok(grids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("radio-tariffs")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> CreateRadioTariff(RadioTariff tariff)
|
||||||
|
{
|
||||||
|
var id = await _repo.CreateRadioTariffAsync(tariff);
|
||||||
|
return Ok(new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ENDPOINTS DE CÁLCULO (CALCULADORA) ---
|
||||||
|
|
||||||
|
[HttpPost("calculate/graphic")]
|
||||||
|
public async Task<IActionResult> 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; }
|
||||||
|
}
|
||||||
46
src/SIGCM.API/Controllers/FinanceController.cs
Normal file
46
src/SIGCM.API/Controllers/FinanceController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> GetDebtors()
|
||||||
|
{
|
||||||
|
var debtors = await _repo.GetDebtorsAsync();
|
||||||
|
return Ok(debtors);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/SIGCM.API/Controllers/OrdersController.cs
Normal file
64
src/SIGCM.API/Controllers/OrdersController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> GetByClient(int clientId)
|
||||||
|
{
|
||||||
|
var orders = await _orderRepo.GetByClientIdAsync(clientId);
|
||||||
|
return Ok(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener detalle de una orden
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/SIGCM.API/Controllers/ProductsController.cs
Normal file
102
src/SIGCM.API/Controllers/ProductsController.cs
Normal file
@@ -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<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var products = await _repository.GetAllAsync();
|
||||||
|
return Ok(products);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> GetByCompany(int companyId)
|
||||||
|
{
|
||||||
|
var products = await _repository.GetByCompanyIdAsync(companyId);
|
||||||
|
return Ok(products);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")] // Solo Admins crean productos
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> RemoveBundleComponent(int bundleId, int childId)
|
||||||
|
{
|
||||||
|
await _repository.RemoveComponentFromBundleAsync(bundleId, childId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/SIGCM.API/Controllers/SellersController.cs
Normal file
74
src/SIGCM.API/Controllers/SellersController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/SIGCM.Application/DTOs/OrderDtos.cs
Normal file
37
src/SIGCM.Application/DTOs/OrderDtos.cs
Normal file
@@ -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<OrderItemDto> 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;
|
||||||
|
}
|
||||||
12
src/SIGCM.Application/Interfaces/IOrderService.cs
Normal file
12
src/SIGCM.Application/Interfaces/IOrderService.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using SIGCM.Application.DTOs;
|
||||||
|
|
||||||
|
namespace SIGCM.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IOrderService
|
||||||
|
{
|
||||||
|
Task<OrderResultDto> CreateOrderAsync(CreateOrderDto dto);
|
||||||
|
|
||||||
|
// Aquí agregaremos métodos futuros como:
|
||||||
|
// Task CancelOrderAsync(int id);
|
||||||
|
// Task<byte[]> GenerateInvoicePdfAsync(int id);
|
||||||
|
}
|
||||||
15
src/SIGCM.Domain/Entities/ClientProfile.cs
Normal file
15
src/SIGCM.Domain/Entities/ClientProfile.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
12
src/SIGCM.Domain/Entities/Company.cs
Normal file
12
src/SIGCM.Domain/Entities/Company.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
src/SIGCM.Domain/Entities/ExternalClientMapping.cs
Normal file
10
src/SIGCM.Domain/Entities/ExternalClientMapping.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
20
src/SIGCM.Domain/Entities/GraphicGrid.cs
Normal file
20
src/SIGCM.Domain/Entities/GraphicGrid.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
26
src/SIGCM.Domain/Entities/Order.cs
Normal file
26
src/SIGCM.Domain/Entities/Order.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
24
src/SIGCM.Domain/Entities/OrderItem.cs
Normal file
24
src/SIGCM.Domain/Entities/OrderItem.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
22
src/SIGCM.Domain/Entities/Product.cs
Normal file
22
src/SIGCM.Domain/Entities/Product.cs
Normal file
@@ -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.
|
||||||
|
}
|
||||||
13
src/SIGCM.Domain/Entities/ProductBundle.cs
Normal file
13
src/SIGCM.Domain/Entities/ProductBundle.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
16
src/SIGCM.Domain/Entities/RadioTariff.cs
Normal file
16
src/SIGCM.Domain/Entities/RadioTariff.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
12
src/SIGCM.Domain/Entities/SellerProfile.cs
Normal file
12
src/SIGCM.Domain/Entities/SellerProfile.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
15
src/SIGCM.Domain/Interfaces/IAdvertisingRepository.cs
Normal file
15
src/SIGCM.Domain/Interfaces/IAdvertisingRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IAdvertisingRepository
|
||||||
|
{
|
||||||
|
// Gráfica
|
||||||
|
Task<IEnumerable<GraphicGrid>> GetGridsByCompanyAsync(int companyId);
|
||||||
|
Task<GraphicGrid?> GetGridByIdAsync(int id);
|
||||||
|
Task<int> CreateGridAsync(GraphicGrid grid);
|
||||||
|
|
||||||
|
// Radio
|
||||||
|
Task<IEnumerable<RadioTariff>> GetRadioTariffsByCompanyAsync(int companyId);
|
||||||
|
Task<int> CreateRadioTariffAsync(RadioTariff tariff);
|
||||||
|
}
|
||||||
15
src/SIGCM.Domain/Interfaces/IClientProfileRepository.cs
Normal file
15
src/SIGCM.Domain/Interfaces/IClientProfileRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IClientProfileRepository
|
||||||
|
{
|
||||||
|
Task<ClientProfile?> GetProfileAsync(int userId);
|
||||||
|
Task UpsertProfileAsync(ClientProfile profile);
|
||||||
|
|
||||||
|
// La métrica crítica: Suma de (TotalAmount - Pagado) de órdenes pendientes
|
||||||
|
Task<decimal> CalculateCurrentDebtAsync(int userId);
|
||||||
|
|
||||||
|
// Listado de clientes con deuda (Reporte de Morosos)
|
||||||
|
Task<IEnumerable<ClientProfile>> GetDebtorsAsync();
|
||||||
|
}
|
||||||
16
src/SIGCM.Domain/Interfaces/IOrderRepository.cs
Normal file
16
src/SIGCM.Domain/Interfaces/IOrderRepository.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IOrderRepository
|
||||||
|
{
|
||||||
|
// Crear una orden completa con sus ítems transaccionalmente
|
||||||
|
Task<int> CreateOrderAsync(Order order, IEnumerable<OrderItem> items);
|
||||||
|
|
||||||
|
Task<Order?> GetByIdAsync(int id);
|
||||||
|
Task<IEnumerable<Order>> GetByClientIdAsync(int clientId);
|
||||||
|
Task<IEnumerable<OrderItem>> GetItemsByOrderIdAsync(int orderId);
|
||||||
|
|
||||||
|
// Gestión de Estados (Cta Cte -> Pagado)
|
||||||
|
Task UpdatePaymentStatusAsync(int orderId, string status);
|
||||||
|
}
|
||||||
16
src/SIGCM.Domain/Interfaces/IProductRepository.cs
Normal file
16
src/SIGCM.Domain/Interfaces/IProductRepository.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IProductRepository
|
||||||
|
{
|
||||||
|
Task<IEnumerable<Product>> GetAllAsync();
|
||||||
|
Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId);
|
||||||
|
Task<Product?> GetByIdAsync(int id);
|
||||||
|
Task<int> CreateAsync(Product product);
|
||||||
|
Task UpdateAsync(Product product);
|
||||||
|
Task<IEnumerable<Company>> GetAllCompaniesAsync(); // Helper para no crear un repo solo para esto por ahora
|
||||||
|
Task<IEnumerable<ProductBundle>> GetBundleComponentsAsync(int parentProductId);
|
||||||
|
Task AddComponentToBundleAsync(ProductBundle bundle);
|
||||||
|
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
|
||||||
|
}
|
||||||
18
src/SIGCM.Domain/Interfaces/ISellerRepository.cs
Normal file
18
src/SIGCM.Domain/Interfaces/ISellerRepository.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface ISellerRepository
|
||||||
|
{
|
||||||
|
// Obtener perfil específico (con datos de usuario)
|
||||||
|
Task<SellerProfile?> GetProfileAsync(int userId);
|
||||||
|
|
||||||
|
// Guardar o actualizar configuración del vendedor
|
||||||
|
Task UpsertProfileAsync(SellerProfile profile);
|
||||||
|
|
||||||
|
// Listar todos los usuarios que son vendedores activos
|
||||||
|
Task<IEnumerable<dynamic>> GetAllActiveSellersAsync();
|
||||||
|
|
||||||
|
// Obtener métricas rápidas (Total vendido en el mes)
|
||||||
|
Task<decimal> GetMonthlySalesAsync(int sellerId, DateTime month);
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<NotificationRepository>();
|
services.AddScoped<NotificationRepository>();
|
||||||
services.AddScoped<ICouponRepository, CouponRepository>();
|
services.AddScoped<ICouponRepository, CouponRepository>();
|
||||||
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
|
services.AddScoped<IListingNoteRepository, ListingNoteRepository>();
|
||||||
|
services.AddScoped<IProductRepository, ProductRepository>();
|
||||||
|
services.AddScoped<IOrderRepository, OrderRepository>();
|
||||||
|
services.AddScoped<IOrderService, OrderService>();
|
||||||
|
services.AddScoped<IAdvertisingRepository, AdvertisingRepository>();
|
||||||
|
services.AddScoped<ISellerRepository, SellerRepository>();
|
||||||
|
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
|
||||||
|
|
||||||
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
||||||
services.AddScoped<MercadoPagoService>(sp =>
|
services.AddScoped<MercadoPagoService>(sp =>
|
||||||
|
|||||||
@@ -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<IEnumerable<GraphicGrid>> GetGridsByCompanyAsync(int companyId)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QueryAsync<GraphicGrid>(
|
||||||
|
"SELECT * FROM GraphicGrids WHERE CompanyId = @CompanyId AND IsActive = 1",
|
||||||
|
new { CompanyId = companyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GraphicGrid?> GetGridByIdAsync(int id)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<GraphicGrid>(
|
||||||
|
"SELECT * FROM GraphicGrids WHERE Id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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<int>(sql, grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RADIO ---
|
||||||
|
public async Task<IEnumerable<RadioTariff>> GetRadioTariffsByCompanyAsync(int companyId)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QueryAsync<RadioTariff>(
|
||||||
|
"SELECT * FROM RadioTariffs WHERE CompanyId = @CompanyId AND IsActive = 1",
|
||||||
|
new { CompanyId = companyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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<int>(sql, tariff);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/SIGCM.Infrastructure/Repositories/ClientProfileRepository.cs
Normal file
103
src/SIGCM.Infrastructure/Repositories/ClientProfileRepository.cs
Normal file
@@ -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<ClientProfile?> 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<ClientProfile>(sql, new { UserId = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertProfileAsync(ClientProfile profile)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
var exists = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"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<decimal> 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<decimal>(sql, new { UserId = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ClientProfile>> 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<ClientProfile>(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/SIGCM.Infrastructure/Repositories/OrderRepository.cs
Normal file
101
src/SIGCM.Infrastructure/Repositories/OrderRepository.cs
Normal file
@@ -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<int> CreateOrderAsync(Order order, IEnumerable<OrderItem> 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<int>(
|
||||||
|
"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<int>(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<Order?> 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<Order>(sql, new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Order>> GetByClientIdAsync(int clientId)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QueryAsync<Order>(
|
||||||
|
"SELECT * FROM Orders WHERE ClientId = @ClientId ORDER BY CreatedAt DESC",
|
||||||
|
new { ClientId = clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<OrderItem>> 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<OrderItem>(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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/SIGCM.Infrastructure/Repositories/ProductRepository.cs
Normal file
129
src/SIGCM.Infrastructure/Repositories/ProductRepository.cs
Normal file
@@ -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<IEnumerable<Product>> 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<Product>(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> 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<Product>(sql, new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QueryAsync<Product>(
|
||||||
|
"SELECT * FROM Products WHERE CompanyId = @Id AND IsActive = 1",
|
||||||
|
new { Id = companyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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<int>(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<IEnumerable<Company>> GetAllCompaniesAsync()
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
return await conn.QueryAsync<Company>("SELECT * FROM Companies WHERE IsActive = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ProductBundle>> 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<ProductBundle, Product, ProductBundle>(
|
||||||
|
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<int>(
|
||||||
|
@"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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/SIGCM.Infrastructure/Repositories/SellerRepository.cs
Normal file
78
src/SIGCM.Infrastructure/Repositories/SellerRepository.cs
Normal file
@@ -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<SellerProfile?> 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<SellerProfile>(sql, new { UserId = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertProfileAsync(SellerProfile profile)
|
||||||
|
{
|
||||||
|
using var conn = _db.CreateConnection();
|
||||||
|
var exists = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"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<IEnumerable<dynamic>> 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<decimal> 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<decimal>(sql, new { SellerId = sellerId, Start = start, End = end });
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/SIGCM.Infrastructure/Services/OrderService.cs
Normal file
222
src/SIGCM.Infrastructure/Services/OrderService.cs
Normal file
@@ -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<OrderResultDto> 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<OrderItem>();
|
||||||
|
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()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user