CheckPoint: Avances Varios
This commit is contained in:
52
src/SIGCM.API/Controllers/ExportsController.cs
Normal file
52
src/SIGCM.API/Controllers/ExportsController.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "Admin,Diagramador")]
|
||||
public class ExportsController : ControllerBase
|
||||
{
|
||||
private readonly IListingRepository _repository;
|
||||
|
||||
public ExportsController(IListingRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
[HttpGet("diagram")]
|
||||
public async Task<IActionResult> DownloadDiagram([FromQuery] DateTime date)
|
||||
{
|
||||
var listings = await _repository.GetListingsForPrintAsync(date);
|
||||
|
||||
// Agrupar por Rubro
|
||||
var grouped = listings.GroupBy(l => l.CategoryName ?? "Varios");
|
||||
|
||||
// Construir XML usando XDocument (más seguro y limpio que StringBuilder)
|
||||
var doc = new XDocument(
|
||||
new XElement("Edition",
|
||||
new XAttribute("date", date.ToString("yyyy-MM-dd")),
|
||||
from g in grouped
|
||||
select new XElement("Category",
|
||||
new XAttribute("name", g.Key),
|
||||
from l in g
|
||||
select new XElement("Ad",
|
||||
new XAttribute("id", l.Id),
|
||||
// Usamos CDATA para proteger caracteres especiales en el texto
|
||||
new XCData(l.PrintText ?? l.Description ?? "")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Convertir a bytes
|
||||
var xmlBytes = Encoding.UTF8.GetBytes(doc.ToString());
|
||||
var fileName = $"diagrama_{date:yyyy-MM-dd}.xml";
|
||||
|
||||
return File(xmlBytes, "application/xml", fileName);
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,24 @@ public class ImagesController : ControllerBase
|
||||
{
|
||||
if (file == null || file.Length == 0) return BadRequest("File is empty");
|
||||
|
||||
// Basic validation
|
||||
// Validaciones básicas
|
||||
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!allowedExtensions.Contains(ext)) return BadRequest("Invalid file type");
|
||||
|
||||
// Ensure directory exists
|
||||
var uploadDir = Path.Combine(_env.WebRootPath, "uploads", "listings", listingId.ToString());
|
||||
Directory.CreateDirectory(uploadDir);
|
||||
// Si WebRootPath es nulo, construimos la ruta manualmente apuntando a la raíz del contenido + wwwroot
|
||||
string webRootPath = _env.WebRootPath ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
|
||||
|
||||
// Save file
|
||||
// Ruta física donde se guarda
|
||||
var uploadDir = Path.Combine(webRootPath, "uploads", "listings", listingId.ToString());
|
||||
|
||||
// Asegurar que la carpeta exista (esto crea toda la estructura si falta)
|
||||
if (!Directory.Exists(uploadDir))
|
||||
{
|
||||
Directory.CreateDirectory(uploadDir);
|
||||
}
|
||||
|
||||
// Guardar archivo
|
||||
var fileName = $"{Guid.NewGuid()}{ext}";
|
||||
var filePath = Path.Combine(uploadDir, fileName);
|
||||
|
||||
@@ -40,14 +48,16 @@ public class ImagesController : ControllerBase
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
// Guardar metadata en BD
|
||||
// Ojo: La URL que guardamos en BD debe ser relativa para que el navegador la entienda
|
||||
var relativeUrl = $"/uploads/listings/{listingId}/{fileName}";
|
||||
var image = new ListingImage
|
||||
{
|
||||
ListingId = listingId,
|
||||
|
||||
var image = new ListingImage
|
||||
{
|
||||
ListingId = listingId,
|
||||
Url = relativeUrl,
|
||||
IsMainInfo = false, // Logic to set first as main could be added here
|
||||
DisplayOrder = 0
|
||||
IsMainInfo = false,
|
||||
DisplayOrder = 0
|
||||
};
|
||||
await _repository.AddAsync(image);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public class ListingsController : ControllerBase
|
||||
Price = dto.Price,
|
||||
Currency = dto.Currency,
|
||||
UserId = dto.UserId,
|
||||
Status = "Draft",
|
||||
Status = "Published", // Auto publish for now
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -36,11 +36,28 @@ public class ListingsController : ControllerBase
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] string? q, [FromQuery] int? categoryId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(q) && !categoryId.HasValue)
|
||||
{
|
||||
var listings = await _repository.GetAllAsync();
|
||||
|
||||
// Populate images for list view (simplified N+1 for now, fix later with JOIN)
|
||||
// Ideally Repo returns a DTO with MainImage
|
||||
// We will fetch images separately in frontend or update repo later for performance.
|
||||
return Ok(listings);
|
||||
}
|
||||
|
||||
var results = await _repository.SearchAsync(q, categoryId);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(int id)
|
||||
{
|
||||
var listing = await _repository.GetByIdAsync(id);
|
||||
if (listing == null) return NotFound();
|
||||
return Ok(listing);
|
||||
var listingDetail = await _repository.GetDetailByIdAsync(id);
|
||||
if (listingDetail == null) return NotFound();
|
||||
return Ok(listingDetail);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/SIGCM.API/Controllers/PricingController.cs
Normal file
88
src/SIGCM.API/Controllers/PricingController.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using SIGCM.Infrastructure.Services;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize] // Requiere login, pero permite Admin y Cajero por defecto
|
||||
public class PricingController : ControllerBase
|
||||
{
|
||||
private readonly PricingService _service;
|
||||
private readonly PricingRepository _repository;
|
||||
|
||||
public PricingController(PricingService service, PricingRepository repository)
|
||||
{
|
||||
_service = service;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
// Usado por: Panel Mostrador (Cajero) y Admin
|
||||
[HttpPost("calculate")]
|
||||
public async Task<IActionResult> Calculate(CalculatePriceRequest request)
|
||||
{
|
||||
var result = await _service.CalculateAsync(request);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// --- Endpoints de Configuración (Solo Admin) ---
|
||||
|
||||
// Obtener reglas de un rubro específico
|
||||
[HttpGet("{categoryId}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetConfig(int categoryId)
|
||||
{
|
||||
var config = await _repository.GetByCategoryIdAsync(categoryId);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// Guardar/Actualizar reglas
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> SaveConfig(CategoryPricing pricing)
|
||||
{
|
||||
if (pricing.CategoryId == 0) return BadRequest("CategoryId es requerido");
|
||||
|
||||
await _repository.UpsertPricingAsync(pricing);
|
||||
return Ok(new { message = "Configuración guardada exitosamente" });
|
||||
}
|
||||
|
||||
// --- ENDPOINTS DE PROMOCIONES ---
|
||||
|
||||
[HttpGet("promotions")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetPromotions()
|
||||
{
|
||||
var promos = await _repository.GetAllPromotionsAsync();
|
||||
return Ok(promos);
|
||||
}
|
||||
|
||||
[HttpPost("promotions")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> CreatePromotion(Promotion promo)
|
||||
{
|
||||
var id = await _repository.CreatePromotionAsync(promo);
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
[HttpPut("promotions/{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdatePromotion(int id, Promotion promo)
|
||||
{
|
||||
if (id != promo.Id) return BadRequest();
|
||||
await _repository.UpdatePromotionAsync(promo);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("promotions/{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeletePromotion(int id)
|
||||
{
|
||||
await _repository.DeletePromotionAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "Admin")] // Only admins can manage users
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserRepository _repository;
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SIGCM.Infrastructure;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// 1. Agregar servicios al contenedor.
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// 2. Configurar Autenticación JWT
|
||||
var key = Encoding.ASCII.GetBytes(builder.Configuration["Jwt:Key"]!);
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.SaveToken = true;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Agregar Capa de Infraestructura
|
||||
builder.Services.AddInfrastructure();
|
||||
|
||||
// 4. Configurar CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowFrontend",
|
||||
policy =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:5175")
|
||||
policy.WithOrigins(
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175",
|
||||
"http://localhost:5177")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
@@ -23,7 +57,8 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
// --- Configuración del Pipeline HTTP ---
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -31,19 +66,21 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles(); // Enable static files for images
|
||||
app.UseStaticFiles(); // Para servir imágenes
|
||||
|
||||
app.UseCors("AllowFrontend");
|
||||
|
||||
// IMPORTANTE: El orden importa aquí
|
||||
app.UseAuthentication(); // <- Esto faltaba
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// Initialize DB
|
||||
// Inicializar BD
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<DbInitializer>();
|
||||
await initializer.InitializeAsync();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
app.Run();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
@@ -1,6 +0,0 @@
|
||||
namespace SIGCM.Application;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -9,14 +9,38 @@ public class CreateListingDto
|
||||
public decimal Price { get; set; }
|
||||
public string Currency { get; set; } = "ARS";
|
||||
public int? UserId { get; set; }
|
||||
|
||||
// Dictionary of AttributeDefinitionId -> Value
|
||||
public Dictionary<int, string> Attributes { get; set; } = new();
|
||||
public string? PrintText { get; set; }
|
||||
public string? PrintFontSize { get; set; }
|
||||
public string? PrintAlignment { get; set; }
|
||||
public int PrintDaysCount { get; set; }
|
||||
}
|
||||
|
||||
public class ListingDto : CreateListingDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string Status { get; set; }
|
||||
public required string Status { get; set; }
|
||||
}
|
||||
|
||||
public class ListingDetailDto : ListingDto
|
||||
{
|
||||
public IEnumerable<ListingAttributeDto> AttributeValues { get; set; } = new List<ListingAttributeDto>();
|
||||
public IEnumerable<ListingImageDto> Images { get; set; } = new List<ListingImageDto>();
|
||||
}
|
||||
|
||||
public class ListingAttributeDto
|
||||
{
|
||||
public int ListingId { get; set; }
|
||||
public int AttributeDefinitionId { get; set; }
|
||||
public required string AttributeName { get; set; }
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
public class ListingImageDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public bool IsMainInfo { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
24
src/SIGCM.Application/DTOs/PricingDtos.cs
Normal file
24
src/SIGCM.Application/DTOs/PricingDtos.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace SIGCM.Application.DTOs;
|
||||
|
||||
public class CalculatePriceRequest
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public required string Text { get; set; }
|
||||
public int Days { get; set; }
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsFrame { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
}
|
||||
|
||||
public class CalculatePriceResponse
|
||||
{
|
||||
public decimal TotalPrice { get; set; }
|
||||
public int WordCount { get; set; }
|
||||
public int SpecialCharCount { get; set; }
|
||||
public decimal BaseCost { get; set; }
|
||||
public decimal ExtraCost { get; set; }
|
||||
public decimal Surcharges { get; set; }
|
||||
public decimal Discount { get; set; }
|
||||
public string Details { get; set; } = string.Empty;
|
||||
public string AppliedPromotion { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace SIGCM.Domain;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
14
src/SIGCM.Domain/Entities/CategoryPricing.cs
Normal file
14
src/SIGCM.Domain/Entities/CategoryPricing.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class CategoryPricing
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public decimal BasePrice { get; set; }
|
||||
public int BaseWordCount { get; set; }
|
||||
public decimal ExtraWordPrice { get; set; }
|
||||
public string SpecialChars { get; set; } = "!";
|
||||
public decimal SpecialCharPrice { get; set; }
|
||||
public decimal BoldSurcharge { get; set; }
|
||||
public decimal FrameSurcharge { get; set; }
|
||||
}
|
||||
@@ -8,10 +8,19 @@ public class Listing
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public string Currency { get; set; } = "ARS"; // ARS, USD
|
||||
public string Currency { get; set; } = "ARS";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string Status { get; set; } = "Draft"; // Draft, Published, Sold, Paused
|
||||
public string Status { get; set; } = "Draft";
|
||||
public int? UserId { get; set; }
|
||||
|
||||
// Navigation properties logic will be handled manually via repositories in Dapper
|
||||
}
|
||||
|
||||
// Propiedades para impresión
|
||||
public string? PrintText { get; set; }
|
||||
public DateTime? PrintStartDate { get; set; }
|
||||
public int PrintDaysCount { get; set; }
|
||||
public string PrintFontSize { get; set; } = "normal";
|
||||
public string PrintAlignment { get; set; } = "left";
|
||||
|
||||
// Propiedades auxiliares (no están en la tabla Listings, vienen de Joins/Subqueries)
|
||||
public string? CategoryName { get; set; }
|
||||
public string? MainImageUrl { get; set; }
|
||||
}
|
||||
13
src/SIGCM.Domain/Entities/Promotion.cs
Normal file
13
src/SIGCM.Domain/Entities/Promotion.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class Promotion
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int? CategoryId { get; set; }
|
||||
public int MinDays { get; set; }
|
||||
public string? DaysOfWeek { get; set; } // "0,6"
|
||||
public decimal DiscountPercentage { get; set; }
|
||||
public decimal DiscountFixedAmount { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using SIGCM.Domain.Models;
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
@@ -6,5 +7,8 @@ public interface IListingRepository
|
||||
{
|
||||
Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes);
|
||||
Task<Listing?> GetByIdAsync(int id);
|
||||
Task<ListingDetail?> GetDetailByIdAsync(int id);
|
||||
Task<IEnumerable<Listing>> GetAllAsync();
|
||||
}
|
||||
Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId);
|
||||
Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime date);
|
||||
}
|
||||
15
src/SIGCM.Domain/Models/ListingDetail.cs
Normal file
15
src/SIGCM.Domain/Models/ListingDetail.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.Domain.Models;
|
||||
|
||||
public class ListingDetail
|
||||
{
|
||||
public required Listing Listing { get; set; }
|
||||
public required IEnumerable<ListingAttributeValueWithName> Attributes { get; set; }
|
||||
public required IEnumerable<ListingImage> Images { get; set; }
|
||||
}
|
||||
|
||||
public class ListingAttributeValueWithName : ListingAttributeValue
|
||||
{
|
||||
public required string AttributeName { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace SIGCM.Infrastructure;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Dapper;
|
||||
// Asegúrate de que BCrypt.Net esté instalado en este proyecto o referenciado
|
||||
// Si no te reconoce BCrypt, avísame, pero debería funcionar porque ya se usa en AuthService.
|
||||
|
||||
namespace SIGCM.Infrastructure.Data;
|
||||
|
||||
@@ -14,60 +16,7 @@ public class DbInitializer
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
|
||||
var sql = @"
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
|
||||
BEGIN
|
||||
CREATE TABLE Users (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
Username NVARCHAR(50) NOT NULL UNIQUE,
|
||||
PasswordHash NVARCHAR(255) NOT NULL,
|
||||
Role NVARCHAR(20) NOT NULL,
|
||||
Email NVARCHAR(100) NULL,
|
||||
CreatedAt DATETIME DEFAULT GETUTCDATE()
|
||||
);
|
||||
|
||||
-- Seed generic admin (password: admin123)
|
||||
-- Hash created with BCrypt
|
||||
INSERT INTO Users (Username, PasswordHash, Role)
|
||||
VALUES ('admin', '$2a$11$u.w..ExampleHashPlaceholder...', 'Admin');
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Categories')
|
||||
BEGIN
|
||||
CREATE TABLE Categories (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
ParentId INT NULL,
|
||||
Name NVARCHAR(100) NOT NULL,
|
||||
Slug NVARCHAR(100) NOT NULL,
|
||||
Active BIT DEFAULT 1,
|
||||
FOREIGN KEY (ParentId) REFERENCES Categories(Id)
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Operations')
|
||||
BEGIN
|
||||
CREATE TABLE Operations (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
Name NVARCHAR(50) NOT NULL UNIQUE
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'CategoryOperations')
|
||||
BEGIN
|
||||
CREATE TABLE CategoryOperations (
|
||||
CategoryId INT NOT NULL,
|
||||
OperationId INT NOT NULL,
|
||||
PRIMARY KEY (CategoryId, OperationId),
|
||||
FOREIGN KEY (CategoryId) REFERENCES Categories(Id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (OperationId) REFERENCES Operations(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
";
|
||||
// Fixing the placeholder hash to a valid one might be necessary if I want to login immediately.
|
||||
// I will update the hash command later or create a small utility to generate one.
|
||||
// For now, I'll remove the INSERT or comment it out until I can generate a real hash in C#.
|
||||
|
||||
var schemaSql = @"
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
|
||||
BEGIN
|
||||
@@ -167,6 +116,22 @@ BEGIN
|
||||
);
|
||||
END
|
||||
";
|
||||
// Ejecutar creación de tablas
|
||||
await connection.ExecuteAsync(schemaSql);
|
||||
|
||||
// --- SEED DE DATOS (Usuario Admin) ---
|
||||
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");
|
||||
|
||||
if (adminCount == 0)
|
||||
{
|
||||
// Creamos el hash válido para la clave del usuario usando la librería del proyecto
|
||||
var passwordHash = BCrypt.Net.BCrypt.HashPassword("Diagonal423");
|
||||
|
||||
var insertAdminSql = @"
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email)
|
||||
VALUES ('admin', @PasswordHash, 'Admin', 'admin@sigcm.com')";
|
||||
|
||||
await connection.ExecuteAsync(insertAdminSql, new { PasswordHash = passwordHash });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Application.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using SIGCM.Infrastructure.Services;
|
||||
|
||||
namespace SIGCM.Infrastructure;
|
||||
|
||||
@@ -15,11 +16,13 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||
services.AddScoped<IOperationRepository, OperationRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<ITokenService, Services.TokenService>();
|
||||
services.AddScoped<IAuthService, Services.AuthService>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
|
||||
services.AddScoped<IListingRepository, ListingRepository>();
|
||||
services.AddScoped<IImageRepository, ImageRepository>();
|
||||
services.AddScoped<PricingRepository>();
|
||||
services.AddScoped<PricingService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,16 @@ public class ListingRepository : IListingRepository
|
||||
try
|
||||
{
|
||||
var sqlListing = @"
|
||||
INSERT INTO Listings (CategoryId, OperationId, Title, Description, Price, Currency, CreatedAt, Status, UserId)
|
||||
VALUES (@CategoryId, @OperationId, @Title, @Description, @Price, @Currency, @CreatedAt, @Status, @UserId);
|
||||
INSERT INTO Listings (
|
||||
CategoryId, OperationId, Title, Description, Price, Currency,
|
||||
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment
|
||||
)
|
||||
VALUES (
|
||||
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
||||
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
var listingId = await conn.QuerySingleAsync<int>(sqlListing, listing, transaction);
|
||||
@@ -59,15 +67,90 @@ public class ListingRepository : IListingRepository
|
||||
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<SIGCM.Domain.Models.ListingDetail?> GetDetailByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT * FROM Listings WHERE Id = @Id;
|
||||
|
||||
SELECT lav.*, ad.Name as AttributeName
|
||||
FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = @Id;
|
||||
|
||||
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
|
||||
";
|
||||
|
||||
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
|
||||
var listing = await multi.ReadSingleOrDefaultAsync<Listing>();
|
||||
if (listing == null) return null;
|
||||
|
||||
var attributes = await multi.ReadAsync<SIGCM.Domain.Models.ListingAttributeValueWithName>();
|
||||
var images = await multi.ReadAsync<ListingImage>();
|
||||
|
||||
return new SIGCM.Domain.Models.ListingDetail
|
||||
{
|
||||
Listing = listing,
|
||||
Attributes = attributes,
|
||||
Images = images
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetAllAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// A simple query for now
|
||||
// Subquery para obtener la imagen principal
|
||||
var sql = @"
|
||||
SELECT l.*
|
||||
SELECT TOP 20 l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
ORDER BY l.CreatedAt DESC";
|
||||
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE 1=1";
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
|
||||
parameters.Add("Query", $"%{query}%");
|
||||
}
|
||||
|
||||
if (categoryId.HasValue)
|
||||
{
|
||||
sql += " AND l.CategoryId = @CategoryId";
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
}
|
||||
|
||||
sql += " ORDER BY l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql, parameters);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime targetDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// La lógica: El aviso debe haber empezado antes o en la fecha target
|
||||
// Y la fecha target debe ser menor a la fecha de inicio + duración
|
||||
var sql = @"
|
||||
SELECT l.*, c.Name as CategoryName
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
WHERE l.PrintStartDate IS NOT NULL
|
||||
AND @TargetDate >= CAST(l.PrintStartDate AS DATE)
|
||||
AND @TargetDate < DATEADD(day, l.PrintDaysCount, CAST(l.PrintStartDate AS DATE))
|
||||
ORDER BY c.Name, l.Title"; // Ordenado por Rubro y luego alfabético
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql, new { TargetDate = targetDate.Date });
|
||||
}
|
||||
}
|
||||
94
src/SIGCM.Infrastructure/Repositories/PricingRepository.cs
Normal file
94
src/SIGCM.Infrastructure/Repositories/PricingRepository.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class PricingRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public PricingRepository(IDbConnectionFactory db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<CategoryPricing?> GetByCategoryIdAsync(int categoryId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
|
||||
"SELECT * FROM CategoryPricing WHERE CategoryId = @Id", new { Id = categoryId });
|
||||
}
|
||||
|
||||
public async Task UpsertPricingAsync(CategoryPricing pricing)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// Lógica de "Si existe actualiza, sino inserta"
|
||||
var exists = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
|
||||
|
||||
if (exists > 0)
|
||||
{
|
||||
var updateSql = @"
|
||||
UPDATE CategoryPricing
|
||||
SET BasePrice = @BasePrice, BaseWordCount = @BaseWordCount,
|
||||
ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars,
|
||||
SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge,
|
||||
FrameSurcharge = @FrameSurcharge
|
||||
WHERE CategoryId = @CategoryId";
|
||||
await conn.ExecuteAsync(updateSql, pricing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var insertSql = @"
|
||||
INSERT INTO CategoryPricing
|
||||
(CategoryId, BasePrice, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge)
|
||||
VALUES
|
||||
(@CategoryId, @BasePrice, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge)";
|
||||
await conn.ExecuteAsync(insertSql, pricing);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Promotion>> GetActivePromotionsAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Promotion>(
|
||||
"SELECT * FROM Promotions WHERE IsActive = 1");
|
||||
}
|
||||
|
||||
// --- SECCIÓN PROMOCIONES ---
|
||||
|
||||
public async Task<IEnumerable<Promotion>> GetAllPromotionsAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Promotion>("SELECT * FROM Promotions ORDER BY Id DESC");
|
||||
}
|
||||
|
||||
public async Task<int> CreatePromotionAsync(Promotion promo)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Promotions (Name, CategoryId, MinDays, DaysOfWeek, DiscountPercentage, DiscountFixedAmount, IsActive)
|
||||
VALUES (@Name, @CategoryId, @MinDays, @DaysOfWeek, @DiscountPercentage, @DiscountFixedAmount, @IsActive);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, promo);
|
||||
}
|
||||
|
||||
public async Task UpdatePromotionAsync(Promotion promo)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE Promotions
|
||||
SET Name = @Name, CategoryId = @CategoryId, MinDays = @MinDays,
|
||||
DaysOfWeek = @DaysOfWeek, DiscountPercentage = @DiscountPercentage,
|
||||
DiscountFixedAmount = @DiscountFixedAmount, IsActive = @IsActive
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, promo);
|
||||
}
|
||||
|
||||
public async Task DeletePromotionAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM Promotions WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
111
src/SIGCM.Infrastructure/Services/PricingService.cs
Normal file
111
src/SIGCM.Infrastructure/Services/PricingService.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using SIGCM.Application.DTOs; // Asegúrate de crear este DTO (ver abajo)
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SIGCM.Infrastructure.Services;
|
||||
|
||||
public class PricingService
|
||||
{
|
||||
private readonly PricingRepository _repo;
|
||||
|
||||
public PricingService(PricingRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
|
||||
{
|
||||
// 1. Obtener Reglas
|
||||
var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId);
|
||||
|
||||
// Si no hay configuración para este rubro, devolvemos 0 o un default seguro
|
||||
if (pricing == null) return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = 0,
|
||||
Details = "No hay tarifas configuradas para este rubro."
|
||||
};
|
||||
|
||||
// 2. Análisis del Texto
|
||||
var words = request.Text.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
int realWordCount = words.Length;
|
||||
|
||||
// Contar caracteres especiales configurados en BD (ej: "!")
|
||||
// Escapamos los caracteres por seguridad en Regex
|
||||
string escapedSpecialChars = Regex.Escape(pricing.SpecialChars ?? "!");
|
||||
int specialCharCount = Regex.Matches(request.Text, $"[{escapedSpecialChars}]").Count;
|
||||
|
||||
// 3. Costo Base y Excedente
|
||||
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras
|
||||
|
||||
// ¿Cuántas palabras extra cobramos?
|
||||
// Nota: Los caracteres especiales se cobran aparte según tu requerimiento,
|
||||
// o suman al conteo de palabras. Aquí implemento: Se cobran APARTE.
|
||||
|
||||
int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount);
|
||||
decimal extraWordCost = extraWords * pricing.ExtraWordPrice;
|
||||
decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice;
|
||||
|
||||
currentCost += extraWordCost + specialCharCost;
|
||||
|
||||
// 4. Estilos (Negrita / Recuadro) - Se suman al precio unitario diario
|
||||
if (request.IsBold) currentCost += pricing.BoldSurcharge;
|
||||
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
|
||||
|
||||
// 5. Multiplicar por Días
|
||||
decimal totalBeforeDiscount = currentCost * request.Days;
|
||||
|
||||
// 6. Motor de Promociones
|
||||
var promotions = await _repo.GetActivePromotionsAsync();
|
||||
decimal totalDiscount = 0;
|
||||
List<string> appliedPromos = new();
|
||||
|
||||
foreach (var promo in promotions)
|
||||
{
|
||||
// Filtro por Categoría
|
||||
if (promo.CategoryId.HasValue && promo.CategoryId != request.CategoryId) continue;
|
||||
|
||||
// Filtro por Cantidad de Días
|
||||
if (promo.MinDays > 0 && request.Days < promo.MinDays) continue;
|
||||
|
||||
// Filtro por Días de la Semana
|
||||
if (!string.IsNullOrEmpty(promo.DaysOfWeek))
|
||||
{
|
||||
var targetDays = promo.DaysOfWeek.Split(',').Select(int.Parse).ToList();
|
||||
bool hitsDay = false;
|
||||
// Revisamos cada día que durará el aviso
|
||||
for (int i = 0; i < request.Days; i++)
|
||||
{
|
||||
var currentDay = (int)request.StartDate.AddDays(i).DayOfWeek;
|
||||
if (targetDays.Contains(currentDay)) hitsDay = true;
|
||||
}
|
||||
if (!hitsDay) continue; // No cae en ningún día de promo
|
||||
}
|
||||
|
||||
// Aplicar Descuento
|
||||
if (promo.DiscountPercentage > 0)
|
||||
{
|
||||
decimal discountVal = totalBeforeDiscount * (promo.DiscountPercentage / 100m);
|
||||
totalDiscount += discountVal;
|
||||
appliedPromos.Add($"{promo.Name} (-{promo.DiscountPercentage}%)");
|
||||
}
|
||||
if (promo.DiscountFixedAmount > 0)
|
||||
{
|
||||
totalDiscount += promo.DiscountFixedAmount;
|
||||
appliedPromos.Add($"{promo.Name} (-${promo.DiscountFixedAmount})");
|
||||
}
|
||||
}
|
||||
|
||||
return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
||||
BaseCost = pricing.BasePrice,
|
||||
ExtraCost = extraWordCost + specialCharCost,
|
||||
Surcharges = (request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0),
|
||||
Discount = totalDiscount,
|
||||
WordCount = realWordCount,
|
||||
SpecialCharCount = specialCharCount,
|
||||
Details = $"Base: ${pricing.BasePrice} | Extras: ${extraWordCost + specialCharCost} | Desc: -${totalDiscount} ({string.Join(", ", appliedPromos)})"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user