diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs new file mode 100644 index 0000000..c9ffddc --- /dev/null +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -0,0 +1,301 @@ +using foodmarket.Application.Common; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Sales; + +[ApiController] +[Authorize] +[Route("api/sales/retail")] +public class RetailSalesController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + + public RetailSalesController(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public record RetailSaleListRow( + Guid Id, string Number, DateTime Date, RetailSaleStatus Status, + Guid StoreId, string StoreName, + Guid? RetailPointId, string? RetailPointName, + Guid? CustomerId, string? CustomerName, + Guid CurrencyId, string CurrencyCode, + decimal Total, PaymentMethod Payment, int LineCount, + DateTime? PostedAt); + + public record RetailSaleLineDto( + Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, + decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder); + + public record RetailSaleDto( + Guid Id, string Number, DateTime Date, RetailSaleStatus Status, + Guid StoreId, string StoreName, + Guid? RetailPointId, string? RetailPointName, + Guid? CustomerId, string? CustomerName, + Guid CurrencyId, string CurrencyCode, + decimal Subtotal, decimal DiscountTotal, decimal Total, + PaymentMethod Payment, decimal PaidCash, decimal PaidCard, + string? Notes, DateTime? PostedAt, + IReadOnlyList Lines); + + public record RetailSaleLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice, decimal Discount, decimal VatPercent); + public record RetailSaleInput( + DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId, + PaymentMethod Payment, decimal PaidCash, decimal PaidCard, + string? Notes, + IReadOnlyList Lines); + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] RetailSaleStatus? status, + [FromQuery] Guid? storeId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + CancellationToken ct) + { + var q = from s in _db.RetailSales.AsNoTracking() + join st in _db.Stores on s.StoreId equals st.Id + join cu in _db.Currencies on s.CurrencyId equals cu.Id + select new { s, st, cu }; + + if (status is not null) q = q.Where(x => x.s.Status == status); + if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId); + if (from is not null) q = q.Where(x => x.s.Date >= from); + if (to is not null) q = q.Where(x => x.s.Date < to); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.s.Number.ToLower().Contains(s)); + } + + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number) + .Skip(req.Skip).Take(req.Take) + .Select(x => new RetailSaleListRow( + x.s.Id, x.s.Number, x.s.Date, x.s.Status, + x.st.Id, x.st.Name, + x.s.RetailPointId, + x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(), + x.s.CustomerId, + x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(), + x.cu.Id, x.cu.Code, + x.s.Total, x.s.Payment, + x.s.Lines.Count, + x.s.PostedAt)) + .ToListAsync(ct); + + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var dto = await GetInternal(id, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost, Authorize(Roles = "Admin,Manager,Cashier")] + public async Task> Create([FromBody] RetailSaleInput input, CancellationToken ct) + { + var number = await GenerateNumberAsync(input.Date, ct); + var sale = new RetailSale + { + Number = number, + Date = input.Date, + Status = RetailSaleStatus.Draft, + StoreId = input.StoreId, + RetailPointId = input.RetailPointId, + CustomerId = input.CustomerId, + CurrencyId = input.CurrencyId, + Payment = input.Payment, + PaidCash = input.PaidCash, + PaidCard = input.PaidCard, + Notes = input.Notes, + }; + ApplyLines(sale, input.Lines); + _db.RetailSales.Add(sale); + await _db.SaveChangesAsync(ct); + var dto = await GetInternal(sale.Id, ct); + return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")] + public async Task Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct) + { + var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (sale is null) return NotFound(); + if (sale.Status != RetailSaleStatus.Draft) + return Conflict(new { error = "Только черновик может быть изменён." }); + + sale.Date = input.Date; + sale.StoreId = input.StoreId; + sale.RetailPointId = input.RetailPointId; + sale.CustomerId = input.CustomerId; + sale.CurrencyId = input.CurrencyId; + sale.Payment = input.Payment; + sale.PaidCash = input.PaidCash; + sale.PaidCard = input.PaidCard; + sale.Notes = input.Notes; + + _db.RetailSaleLines.RemoveRange(sale.Lines); + sale.Lines.Clear(); + ApplyLines(sale, input.Lines); + + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Delete(Guid id, CancellationToken ct) + { + var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct); + if (sale is null) return NotFound(); + if (sale.Status != RetailSaleStatus.Draft) + return Conflict(new { error = "Нельзя удалить проведённый чек." }); + _db.RetailSales.Remove(sale); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Cashier")] + public async Task Post(Guid id, CancellationToken ct) + { + var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (sale is null) return NotFound(); + if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." }); + if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." }); + + foreach (var line in sale.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: sale.StoreId, + Quantity: -line.Quantity, // negative: товар уходит со склада + Type: MovementType.RetailSale, + DocumentType: "retail-sale", + DocumentId: sale.Id, + DocumentNumber: sale.Number, + UnitCost: line.UnitPrice, + OccurredAt: sale.Date), ct); + } + + sale.Status = RetailSaleStatus.Posted; + sale.PostedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (sale is null) return NotFound(); + if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." }); + + foreach (var line in sale.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: sale.StoreId, + Quantity: +line.Quantity, // reverse — return stock + Type: MovementType.RetailSale, + DocumentType: "retail-sale-reversal", + DocumentId: sale.Id, + DocumentNumber: sale.Number, + UnitCost: line.UnitPrice, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена чека {sale.Number}"), ct); + } + + sale.Status = RetailSaleStatus.Draft; + sale.PostedAt = null; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private static void ApplyLines(RetailSale sale, IReadOnlyList input) + { + var order = 0; + decimal subtotal = 0, discountTotal = 0; + foreach (var l in input) + { + var lineTotal = l.Quantity * l.UnitPrice - l.Discount; + sale.Lines.Add(new RetailSaleLine + { + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitPrice = l.UnitPrice, + Discount = l.Discount, + LineTotal = lineTotal, + VatPercent = l.VatPercent, + SortOrder = order++, + }); + subtotal += l.Quantity * l.UnitPrice; + discountTotal += l.Discount; + } + sale.Subtotal = subtotal; + sale.DiscountTotal = discountTotal; + sale.Total = subtotal - discountTotal; + } + + private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) + { + var prefix = $"ПР-{date.Year}-"; + var lastNumber = await _db.RetailSales + .Where(s => s.Number.StartsWith(prefix)) + .OrderByDescending(s => s.Number) + .Select(s => s.Number) + .FirstOrDefaultAsync(ct); + var seq = 1; + if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last)) + seq = last + 1; + return $"{prefix}{seq:D6}"; + } + + private async Task GetInternal(Guid id, CancellationToken ct) + { + var row = await (from s in _db.RetailSales.AsNoTracking() + join st in _db.Stores on s.StoreId equals st.Id + join cu in _db.Currencies on s.CurrencyId equals cu.Id + where s.Id == id + select new { s, st, cu }).FirstOrDefaultAsync(ct); + if (row is null) return null; + + string? rpName = row.s.RetailPointId is null ? null + : await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct); + string? cName = row.s.CustomerId is null ? null + : await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct); + + var lines = await (from l in _db.RetailSaleLines.AsNoTracking() + join p in _db.Products on l.ProductId equals p.Id + join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id + where l.RetailSaleId == id + orderby l.SortOrder + select new RetailSaleLineDto( + l.Id, l.ProductId, p.Name, p.Article, u.Symbol, + l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder)) + .ToListAsync(ct); + + return new RetailSaleDto( + row.s.Id, row.s.Number, row.s.Date, row.s.Status, + row.st.Id, row.st.Name, + row.s.RetailPointId, rpName, + row.s.CustomerId, cName, + row.cu.Id, row.cu.Code, + row.s.Subtotal, row.s.DiscountTotal, row.s.Total, + row.s.Payment, row.s.PaidCash, row.s.PaidCard, + row.s.Notes, row.s.PostedAt, + lines); + } +} diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs new file mode 100644 index 0000000..8bdd4c6 --- /dev/null +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -0,0 +1,70 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +public enum RetailSaleStatus +{ + Draft = 0, + Posted = 1, +} + +public enum PaymentMethod +{ + Cash = 0, + Card = 1, + BankTransfer = 2, + Bonus = 3, + Mixed = 99, +} + +public class RetailSale : TenantEntity +{ + public string Number { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.UtcNow; + public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + public Guid? RetailPointId { get; set; } + public RetailPoint? RetailPoint { get; set; } + + public Guid? CustomerId { get; set; } + public Counterparty? Customer { get; set; } + + public Guid? CashierUserId { get; set; } + + public Guid CurrencyId { get; set; } + public Currency Currency { get; set; } = null!; + + public decimal Subtotal { get; set; } // sum of LineTotal before discount + public decimal DiscountTotal { get; set; } + public decimal Total { get; set; } // = Subtotal - DiscountTotal + + public PaymentMethod Payment { get; set; } = PaymentMethod.Cash; + public decimal PaidCash { get; set; } + public decimal PaidCard { get; set; } + + public string? Notes { get; set; } + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Lines { get; set; } = new List(); +} + +public class RetailSaleLine : TenantEntity +{ + public Guid RetailSaleId { get; set; } + public RetailSale RetailSale { get; set; } = null!; + + public Guid ProductId { get; set; } + public Product Product { get; set; } = null!; + + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal Discount { get; set; } + public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount + public decimal VatPercent { get; set; } // snapshot + public int SortOrder { get; set; } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 92fb05a..6256a4e 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -3,6 +3,7 @@ using foodmarket.Domain.Common; using foodmarket.Domain.Inventory; using foodmarket.Domain.Purchases; +using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Identity; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Persistence.Configurations; @@ -43,6 +44,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Supplies => Set(); public DbSet SupplyLines => Set(); + public DbSet RetailSales => Set(); + public DbSet RetailSaleLines => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -72,6 +76,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ConfigureCatalog(); builder.ConfigureInventory(); builder.ConfigurePurchases(); + builder.ConfigureSales(); // Apply multi-tenant query filter to every entity that implements ITenantEntity foreach (var entityType in builder.Model.GetEntityTypes()) diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs new file mode 100644 index 0000000..3e1f101 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -0,0 +1,47 @@ +using foodmarket.Domain.Sales; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Persistence.Configurations; + +public static class SalesConfigurations +{ + public static void ConfigureSales(this ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("retail_sales"); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.Notes).HasMaxLength(1000); + e.Property(x => x.Subtotal).HasPrecision(18, 4); + e.Property(x => x.DiscountTotal).HasPrecision(18, 4); + e.Property(x => x.Total).HasPrecision(18, 4); + e.Property(x => x.PaidCash).HasPrecision(18, 4); + e.Property(x => x.PaidCard).HasPrecision(18, 4); + + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.Date }); + e.HasIndex(x => new { x.OrganizationId, x.Status }); + e.HasIndex(x => new { x.OrganizationId, x.CashierUserId }); + }); + + b.Entity(e => + { + e.ToTable("retail_sale_lines"); + e.Property(x => x.Quantity).HasPrecision(18, 4); + e.Property(x => x.UnitPrice).HasPrecision(18, 4); + e.Property(x => x.Discount).HasPrecision(18, 4); + e.Property(x => x.LineTotal).HasPrecision(18, 4); + e.Property(x => x.VatPercent).HasPrecision(5, 2); + + e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict); + e.HasIndex(x => new { x.OrganizationId, x.ProductId }); + }); + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs new file mode 100644 index 0000000..70fb2e5 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs @@ -0,0 +1,1921 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260422110503_Phase2c_RetailSale")] + partial class Phase2c_RetailSale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "public"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Bik") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ContactPerson") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Iin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("LegalName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TaxNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OrganizationId", "Bin"); + + b.HasIndex("OrganizationId", "Kind"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("counterparties", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("countries", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Currency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MinorUnit") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("currencies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.PriceType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRetail") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("price_types", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Article") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CountryOfOriginId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSupplierId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAlcohol") + .HasColumnType("boolean"); + + b.Property("IsMarked") + .HasColumnType("boolean"); + + b.Property("IsService") + .HasColumnType("boolean"); + + b.Property("IsWeighed") + .HasColumnType("boolean"); + + b.Property("MaxStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MinStock") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("PurchaseCurrencyId") + .HasColumnType("uuid"); + + b.Property("PurchasePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitOfMeasureId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CountryOfOriginId"); + + b.HasIndex("DefaultSupplierId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("PurchaseCurrencyId"); + + b.HasIndex("UnitOfMeasureId"); + + b.HasIndex("VatRateId"); + + b.HasIndex("OrganizationId", "Article"); + + b.HasIndex("OrganizationId", "IsActive"); + + b.HasIndex("OrganizationId", "Name"); + + b.HasIndex("OrganizationId", "ProductGroupId"); + + b.ToTable("products", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("product_barcodes", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("OrganizationId", "ParentId"); + + b.HasIndex("OrganizationId", "Path"); + + b.ToTable("product_groups", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("product_images", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PriceTypeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("PriceTypeId"); + + b.HasIndex("ProductId", "PriceTypeId") + .IsUnique(); + + b.ToTable("product_prices", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FiscalRegNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FiscalSerial") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("retail_points", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMain") + .HasColumnType("boolean"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("ManagerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name"); + + b.ToTable("stores", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecimalPlaces") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Code") + .IsUnique(); + + b.ToTable("units_of_measure", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.VatRate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsIncludedInPrice") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Percent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("vat_rates", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReservedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "StoreId"); + + b.HasIndex("OrganizationId", "ProductId", "StoreId") + .IsUnique(); + + b.ToTable("stocks", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("DocumentType", "DocumentId"); + + b.HasIndex("OrganizationId", "ProductId", "OccurredAt"); + + b.HasIndex("OrganizationId", "StoreId", "OccurredAt"); + + b.ToTable("stock_movements", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Bin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("organizations", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("SupplierInvoiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupplierInvoiceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("StoreId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.HasIndex("OrganizationId", "SupplierId"); + + b.ToTable("supplies", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SupplyId") + .HasColumnType("uuid"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplyId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("supply_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "public"); + }); + + modelBuilder.Entity("foodmarket.Infrastructure.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("foodmarket.Infrastructure.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Counterparty", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.HasOne("foodmarket.Domain.Catalog.Country", "CountryOfOrigin") + .WithMany() + .HasForeignKey("CountryOfOriginId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "DefaultSupplier") + .WithMany() + .HasForeignKey("DefaultSupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany() + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Currency", "PurchaseCurrency") + .WithMany() + .HasForeignKey("PurchaseCurrencyId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.VatRate", "VatRate") + .WithMany() + .HasForeignKey("VatRateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CountryOfOrigin"); + + b.Navigation("DefaultSupplier"); + + b.Navigation("ProductGroup"); + + b.Navigation("PurchaseCurrency"); + + b.Navigation("UnitOfMeasure"); + + b.Navigation("VatRate"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductBarcode", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Barcodes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.HasOne("foodmarket.Domain.Catalog.ProductGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductImage", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductPrice", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.PriceType", "PriceType") + .WithMany() + .HasForeignKey("PriceTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany("Prices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("PriceType"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.RetailPoint", b => + { + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Inventory.StockMovement", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier") + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Store"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply") + .WithMany("Lines") + .HasForeignKey("SupplyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Supply"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.Product", b => + { + b.Navigation("Barcodes"); + + b.Navigation("Images"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("foodmarket.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.cs b/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.cs new file mode 100644 index 0000000..f3b2978 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.cs @@ -0,0 +1,191 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// + public partial class Phase2c_RetailSale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "retail_sales", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Number = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Date = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "integer", nullable: false), + StoreId = table.Column(type: "uuid", nullable: false), + RetailPointId = table.Column(type: "uuid", nullable: true), + CustomerId = table.Column(type: "uuid", nullable: true), + CashierUserId = table.Column(type: "uuid", nullable: true), + CurrencyId = table.Column(type: "uuid", nullable: false), + Subtotal = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + DiscountTotal = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Total = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Payment = table.Column(type: "integer", nullable: false), + PaidCash = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + PaidCard = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + PostedAt = table.Column(type: "timestamp with time zone", nullable: true), + PostedByUserId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + OrganizationId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_retail_sales", x => x.Id); + table.ForeignKey( + name: "FK_retail_sales_counterparties_CustomerId", + column: x => x.CustomerId, + principalSchema: "public", + principalTable: "counterparties", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_retail_sales_currencies_CurrencyId", + column: x => x.CurrencyId, + principalSchema: "public", + principalTable: "currencies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_retail_sales_retail_points_RetailPointId", + column: x => x.RetailPointId, + principalSchema: "public", + principalTable: "retail_points", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_retail_sales_stores_StoreId", + column: x => x.StoreId, + principalSchema: "public", + principalTable: "stores", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "retail_sale_lines", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RetailSaleId = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Discount = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + LineTotal = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + VatPercent = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + OrganizationId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_retail_sale_lines", x => x.Id); + table.ForeignKey( + name: "FK_retail_sale_lines_products_ProductId", + column: x => x.ProductId, + principalSchema: "public", + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_retail_sale_lines_retail_sales_RetailSaleId", + column: x => x.RetailSaleId, + principalSchema: "public", + principalTable: "retail_sales", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_retail_sale_lines_OrganizationId_ProductId", + schema: "public", + table: "retail_sale_lines", + columns: new[] { "OrganizationId", "ProductId" }); + + migrationBuilder.CreateIndex( + name: "IX_retail_sale_lines_ProductId", + schema: "public", + table: "retail_sale_lines", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_retail_sale_lines_RetailSaleId", + schema: "public", + table: "retail_sale_lines", + column: "RetailSaleId"); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_CurrencyId", + schema: "public", + table: "retail_sales", + column: "CurrencyId"); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_CustomerId", + schema: "public", + table: "retail_sales", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_OrganizationId_CashierUserId", + schema: "public", + table: "retail_sales", + columns: new[] { "OrganizationId", "CashierUserId" }); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_OrganizationId_Date", + schema: "public", + table: "retail_sales", + columns: new[] { "OrganizationId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_OrganizationId_Number", + schema: "public", + table: "retail_sales", + columns: new[] { "OrganizationId", "Number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_OrganizationId_Status", + schema: "public", + table: "retail_sales", + columns: new[] { "OrganizationId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_RetailPointId", + schema: "public", + table: "retail_sales", + column: "RetailPointId"); + + migrationBuilder.CreateIndex( + name: "IX_retail_sales_StoreId", + schema: "public", + table: "retail_sales", + column: "StoreId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "retail_sale_lines", + schema: "public"); + + migrationBuilder.DropTable( + name: "retail_sales", + schema: "public"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 91289d4..febf551 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1273,6 +1273,157 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("supply_lines", "public"); }); + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashierUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaidCard") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PaidCash") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Payment") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostedByUserId") + .HasColumnType("uuid"); + + b.Property("RetailPointId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("uuid"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Total") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("RetailPointId"); + + b.HasIndex("StoreId"); + + b.HasIndex("OrganizationId", "CashierUserId"); + + b.HasIndex("OrganizationId", "Date"); + + b.HasIndex("OrganizationId", "Number") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("retail_sales", "public"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RetailSaleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("RetailSaleId"); + + b.HasIndex("OrganizationId", "ProductId"); + + b.ToTable("retail_sale_lines", "public"); + }); + modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b => { b.Property("Id") @@ -1674,6 +1825,58 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Supply"); }); + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency") + .WithMany() + .HasForeignKey("CurrencyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint") + .WithMany() + .HasForeignKey("RetailPointId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("foodmarket.Domain.Catalog.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Currency"); + + b.Navigation("Customer"); + + b.Navigation("RetailPoint"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b => + { + b.HasOne("foodmarket.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale") + .WithMany("Lines") + .HasForeignKey("RetailSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("RetailSale"); + }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => { b.Navigation("Authorizations"); @@ -1704,6 +1907,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Lines"); }); + + modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b => + { + b.Navigation("Lines"); + }); #pragma warning restore 612, 618 } } diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 54f32ca..8820ace 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -18,6 +18,8 @@ import { StockPage } from '@/pages/StockPage' import { StockMovementsPage } from '@/pages/StockMovementsPage' import { SuppliesPage } from '@/pages/SuppliesPage' import { SupplyEditPage } from '@/pages/SupplyEditPage' +import { RetailSalesPage } from '@/pages/RetailSalesPage' +import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -56,6 +58,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 466acc8..e105798 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -6,7 +6,7 @@ import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag, Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download, - Boxes, History, TruckIcon, + Boxes, History, TruckIcon, ShoppingCart, } from 'lucide-react' import { Logo } from './Logo' @@ -43,6 +43,9 @@ const nav = [ { group: 'Закупки', items: [ { to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' }, ]}, + { group: 'Продажи', items: [ + { to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' }, + ]}, { group: 'Справочники', items: [ { to: '/catalog/countries', icon: Globe, label: 'Страны' }, { to: '/catalog/currencies', icon: Coins, label: 'Валюты' }, diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index eb0c4ab..7c3a1f1 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -97,3 +97,38 @@ export interface SupplyDto { total: number; postedAt: string | null; lines: SupplyLineDto[]; } + +export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const +export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus] + +export const PaymentMethod = { Cash: 0, Card: 1, BankTransfer: 2, Bonus: 3, Mixed: 99 } as const +export type PaymentMethod = (typeof PaymentMethod)[keyof typeof PaymentMethod] + +export interface RetailSaleListRow { + id: string; number: string; date: string; status: RetailSaleStatus; + storeId: string; storeName: string; + retailPointId: string | null; retailPointName: string | null; + customerId: string | null; customerName: string | null; + currencyId: string; currencyCode: string; + total: number; payment: PaymentMethod; lineCount: number; + postedAt: string | null; +} + +export interface RetailSaleLineDto { + id: string | null; productId: string; + productName: string | null; productArticle: string | null; unitSymbol: string | null; + quantity: number; unitPrice: number; discount: number; lineTotal: number; + vatPercent: number; sortOrder: number; +} + +export interface RetailSaleDto { + id: string; number: string; date: string; status: RetailSaleStatus; + storeId: string; storeName: string; + retailPointId: string | null; retailPointName: string | null; + customerId: string | null; customerName: string | null; + currencyId: string; currencyCode: string; + subtotal: number; discountTotal: number; total: number; + payment: PaymentMethod; paidCash: number; paidCard: number; + notes: string | null; postedAt: string | null; + lines: RetailSaleLineDto[]; +} diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx new file mode 100644 index 0000000..d84c8cc --- /dev/null +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -0,0 +1,393 @@ +import { useState, useEffect, type FormEvent, type ReactNode } from 'react' +import { useNavigate, useParams, Link } from 'react-router-dom' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextInput, TextArea, Select } from '@/components/Field' +import { ProductPicker } from '@/components/ProductPicker' +import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups' +import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types' + +interface LineRow { + id?: string + productId: string + productName: string + productArticle: string | null + unitSymbol: string | null + quantity: number + unitPrice: number + discount: number + vatPercent: number +} + +interface Form { + date: string + storeId: string + retailPointId: string + customerId: string + currencyId: string + payment: PaymentMethod + paidCash: number + paidCard: number + notes: string + lines: LineRow[] +} + +const todayIso = () => new Date().toISOString().slice(0, 16) + +const empty: Form = { + date: todayIso(), + storeId: '', retailPointId: '', customerId: '', currencyId: '', + payment: PaymentMethod.Cash, paidCash: 0, paidCard: 0, + notes: '', lines: [], +} + +export function RetailSaleEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const stores = useStores() + const currencies = useCurrencies() + const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP + + const [form, setForm] = useState
(empty) + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState(null) + + const existing = useQuery({ + queryKey: ['/api/sales/retail', id], + queryFn: async () => (await api.get(`/api/sales/retail/${id}`)).data, + enabled: !isNew, + }) + + useEffect(() => { + if (!isNew && existing.data) { + const s = existing.data + setForm({ + date: s.date.slice(0, 16), + storeId: s.storeId, + retailPointId: s.retailPointId ?? '', + customerId: s.customerId ?? '', + currencyId: s.currencyId, + payment: s.payment, + paidCash: s.paidCash, + paidCard: s.paidCard, + notes: s.notes ?? '', + lines: s.lines.map((l) => ({ + id: l.id ?? undefined, + productId: l.productId, + productName: l.productName ?? '', + productArticle: l.productArticle, + unitSymbol: l.unitSymbol, + quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount, + vatPercent: l.vatPercent, + })), + }) + } + }, [isNew, existing.data]) + + useEffect(() => { + if (isNew) { + if (!form.storeId && stores.data?.length) { + setForm((f) => ({ ...f, storeId: stores.data!.find((s) => s.isMain)?.id ?? stores.data![0].id })) + } + if (!form.currencyId && currencies.data?.length) { + setForm((f) => ({ ...f, currencyId: currencies.data!.find((c) => c.code === 'KZT')?.id ?? currencies.data![0].id })) + } + } + }, [isNew, stores.data, currencies.data, form.storeId, form.currencyId]) + + const isDraft = isNew || existing.data?.status === RetailSaleStatus.Draft + const isPosted = existing.data?.status === RetailSaleStatus.Posted + + const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount + const subtotal = form.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0) + const discountTotal = form.lines.reduce((s, l) => s + l.discount, 0) + const grandTotal = subtotal - discountTotal + + const save = useMutation({ + mutationFn: async () => { + const payload = { + date: new Date(form.date).toISOString(), + storeId: form.storeId, + retailPointId: form.retailPointId || null, + customerId: form.customerId || null, + currencyId: form.currencyId, + payment: form.payment, + paidCash: Number(form.paidCash), + paidCard: Number(form.paidCard), + notes: form.notes || null, + lines: form.lines.map((l) => ({ + productId: l.productId, + quantity: l.quantity, + unitPrice: l.unitPrice, + discount: l.discount, + vatPercent: l.vatPercent, + })), + } + if (isNew) { + return (await api.post('/api/sales/retail', payload)).data + } + await api.put(`/api/sales/retail/${id}`, payload) + return null + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/sales/retail'] }) + navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`) + }, + onError: (e: Error) => setError(e.message), + }) + + const post = useMutation({ + mutationFn: async () => { await api.post(`/api/sales/retail/${id}/post`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/sales/retail'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) + existing.refetch() + }, + onError: (e: Error) => setError(e.message), + }) + + const unpost = useMutation({ + mutationFn: async () => { await api.post(`/api/sales/retail/${id}/unpost`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/sales/retail'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) + existing.refetch() + }, + onError: (e: Error) => setError(e.message), + }) + + const remove = useMutation({ + mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) }, + onSuccess: () => navigate('/sales/retail'), + onError: (e: Error) => setError(e.message), + }) + + const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } + + const addLineFromProduct = (p: Product) => { + const retail = p.prices.find((x) => x.priceTypeName?.toLowerCase().includes('розн')) ?? p.prices[0] + setForm({ + ...form, + lines: [...form.lines, { + productId: p.id, + productName: p.name, + productArticle: p.article, + unitSymbol: p.unitSymbol, + quantity: 1, + unitPrice: retail?.amount ?? 0, + discount: 0, + vatPercent: p.vatPercent, + }], + }) + } + const updateLine = (i: number, patch: Partial) => + setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) }) + const removeLine = (i: number) => + setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) }) + + const canSave = !!form.storeId && !!form.currencyId && isDraft + + return ( + +
+
+ + + +
+

+ {isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'} +

+

+ {isPosted + ? Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''} + : 'Черновик — товар не списывается со склада до проведения'} +

+
+
+
+ {isPosted && ( + + )} + {isDraft && !isNew && ( + + )} + {isDraft && ( + + )} + {isDraft && !isNew && ( + + )} +
+
+ +
+
+ {error &&
{error}
} + +
+
+ + setForm({ ...form, date: e.target.value })} /> + + + + + + + + + + + + + + + setForm({ ...form, paidCash: Number(e.target.value) })} /> + + + setForm({ ...form, paidCard: Number(e.target.value) })} /> + + +