Feat Backend ERP 1

This commit is contained in:
2026-01-07 11:34:25 -03:00
parent 81aea41e69
commit fdb221d0fa
29 changed files with 1364 additions and 1 deletions

View 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; }
}

View 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);
}
}

View 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 });
}
}

View 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();
}
}

View 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
});
}
}

View 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;
}

View 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);
}

View 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; }
}

View 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;
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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.
}

View 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; }
}

View 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;
}

View 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; }
}

View 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);
}

View 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();
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -27,13 +27,19 @@ public static class DependencyInjection
services.AddScoped<ClientRepository>(); services.AddScoped<ClientRepository>();
services.AddScoped<AuditRepository>(); services.AddScoped<AuditRepository>();
//services.AddScoped<CashClosingRepository>(); //services.AddScoped<CashClosingRepository>();
services.AddScoped<CashSessionRepository>(); services.AddScoped<CashSessionRepository>();
services.AddScoped<EditionClosureRepository>(); services.AddScoped<EditionClosureRepository>();
services.AddScoped<ImageOptimizationService>(); services.AddScoped<ImageOptimizationService>();
services.AddScoped<IClaimRepository, ClaimRepository>(); services.AddScoped<IClaimRepository, ClaimRepository>();
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 =>

View File

@@ -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);
}
}

View 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);
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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()}";
}
}