From 61f2c21016ae5716e844ec723514fec4828988f4 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:06:08 +0500 Subject: [PATCH] =?UTF-8?q?phase2b:=20Supply=20document=20(=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=91=D0=BC=D0=BA=D0=B0)=20=E2=80=94=20posts=20to=20stoc?= =?UTF-8?q?k=20atomically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain (foodmarket.Domain.Purchases): - Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs, Notes, Total, PostedAt/PostedByUserId, Lines. - SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder. EF: supplies + supply_lines tables, unique index (tenant,Number), indexes by date/status/supplier/product. Migration Phase2b_Supply applied. API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations): - GET list with filters (status, storeId, supplierId, search by number/name), projected columns. - GET {id} with full line list joined to products + units. - POST create draft (lines optional at creation, grand total computed). - PUT update — replaces all lines; rejected if already Posted. - DELETE — drafts only. - POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges). - POST {id}/unpost — reverses with -qty movements tagged "supply-reversal", returns to Draft so edits can resume. - Auto-numbering scans existing numbers matching prefix per year+tenant. Web: - types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto. - /purchases/supplies list (number, date, status badge, supplier, store, line count, total in currency). - /purchases/supplies/new + /:id edit page (sticky top bar with Back / Save / Post / Unpost / Delete; reqisites grid; lines table with inline qty/price and running total + grand total in bottom row). - ProductPicker modal: full-text search over products (name/article/barcode), shows purchase price for quick reference, click to add line. - Sidebar new group "Закупки" → "Приёмки" (TruckIcon). Flow: create draft → add lines via picker → edit qty/price → Save → Post. Posting writes StockMovement rows (visible on Движения) and updates Stock aggregate (visible on Остатки). Unpost reverses in place. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Purchases/SuppliesController.cs | 293 +++ src/food-market.domain/Purchases/Supply.cs | 55 + .../Persistence/AppDbContext.cs | 5 + .../Configurations/PurchasesConfigurations.cs | 42 + .../20260421195644_Phase2b_Supply.Designer.cs | 1713 +++++++++++++++++ .../20260421195644_Phase2b_Supply.cs | 171 ++ .../Migrations/AppDbContextModelSnapshot.cs | 174 ++ src/food-market.web/src/App.tsx | 5 + .../src/components/AppLayout.tsx | 5 +- .../src/components/ProductPicker.tsx | 88 + src/food-market.web/src/lib/types.ts | 28 + .../src/pages/SuppliesPage.tsx | 55 + .../src/pages/SupplyEditPage.tsx | 370 ++++ 13 files changed, 3003 insertions(+), 1 deletion(-) create mode 100644 src/food-market.api/Controllers/Purchases/SuppliesController.cs create mode 100644 src/food-market.domain/Purchases/Supply.cs create mode 100644 src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.cs create mode 100644 src/food-market.web/src/components/ProductPicker.tsx create mode 100644 src/food-market.web/src/pages/SuppliesPage.tsx create mode 100644 src/food-market.web/src/pages/SupplyEditPage.tsx diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs new file mode 100644 index 0000000..94ec84c --- /dev/null +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -0,0 +1,293 @@ +using foodmarket.Application.Common; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Purchases; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Purchases; + +[ApiController] +[Authorize] +[Route("api/purchases/supplies")] +public class SuppliesController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + + public SuppliesController(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public record SupplyListRow( + Guid Id, string Number, DateTime Date, SupplyStatus Status, + Guid SupplierId, string SupplierName, + Guid StoreId, string StoreName, + Guid CurrencyId, string CurrencyCode, + decimal Total, int LineCount, + DateTime? PostedAt); + + public record SupplyLineDto( + Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, + string? UnitSymbol, + decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder); + + public record SupplyDto( + Guid Id, string Number, DateTime Date, SupplyStatus Status, + Guid SupplierId, string SupplierName, + Guid StoreId, string StoreName, + Guid CurrencyId, string CurrencyCode, + string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, + string? Notes, + decimal Total, DateTime? PostedAt, + IReadOnlyList Lines); + + public record SupplyLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice); + public record SupplyInput( + DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, + string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, + string? Notes, + IReadOnlyList Lines); + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] SupplyStatus? status, + [FromQuery] Guid? storeId, + [FromQuery] Guid? supplierId, + CancellationToken ct) + { + var q = from s in _db.Supplies.AsNoTracking() + join cp in _db.Counterparties on s.SupplierId equals cp.Id + 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, cp, 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 (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.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 SupplyListRow( + x.s.Id, x.s.Number, x.s.Date, x.s.Status, + x.cp.Id, x.cp.Name, + x.st.Id, x.st.Name, + x.cu.Id, x.cu.Code, + x.s.Total, + 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,Storekeeper")] + public async Task> Create([FromBody] SupplyInput input, CancellationToken ct) + { + var number = await GenerateNumberAsync(input.Date, ct); + var supply = new Supply + { + Number = number, + Date = input.Date, + Status = SupplyStatus.Draft, + SupplierId = input.SupplierId, + StoreId = input.StoreId, + CurrencyId = input.CurrencyId, + SupplierInvoiceNumber = input.SupplierInvoiceNumber, + SupplierInvoiceDate = input.SupplierInvoiceDate, + Notes = input.Notes, + }; + + var order = 0; + foreach (var l in input.Lines) + { + supply.Lines.Add(new SupplyLine + { + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitPrice = l.UnitPrice, + LineTotal = l.Quantity * l.UnitPrice, + SortOrder = order++, + }); + } + supply.Total = supply.Lines.Sum(x => x.LineTotal); + + _db.Supplies.Add(supply); + await _db.SaveChangesAsync(ct); + var dto = await GetInternal(supply.Id, ct); + return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) + { + var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (supply is null) return NotFound(); + if (supply.Status != SupplyStatus.Draft) + return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); + + supply.Date = input.Date; + supply.SupplierId = input.SupplierId; + supply.StoreId = input.StoreId; + supply.CurrencyId = input.CurrencyId; + supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber; + supply.SupplierInvoiceDate = input.SupplierInvoiceDate; + supply.Notes = input.Notes; + + // Replace lines wholesale (simple, idempotent). + _db.SupplyLines.RemoveRange(supply.Lines); + supply.Lines.Clear(); + var order = 0; + foreach (var l in input.Lines) + { + supply.Lines.Add(new SupplyLine + { + SupplyId = supply.Id, + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitPrice = l.UnitPrice, + LineTotal = l.Quantity * l.UnitPrice, + SortOrder = order++, + }); + } + supply.Total = supply.Lines.Sum(x => x.LineTotal); + + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Delete(Guid id, CancellationToken ct) + { + var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct); + if (supply is null) return NotFound(); + if (supply.Status != SupplyStatus.Draft) + return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); + _db.Supplies.Remove(supply); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Post(Guid id, CancellationToken ct) + { + var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (supply is null) return NotFound(); + if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); + if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); + + foreach (var line in supply.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: supply.StoreId, + Quantity: line.Quantity, + Type: MovementType.Supply, + DocumentType: "supply", + DocumentId: supply.Id, + DocumentNumber: supply.Number, + UnitCost: line.UnitPrice, + OccurredAt: supply.Date), ct); + } + + supply.Status = SupplyStatus.Posted; + supply.PostedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); + if (supply is null) return NotFound(); + if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." }); + + // Reverse: negative movements with same document reference + foreach (var line in supply.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: supply.StoreId, + Quantity: -line.Quantity, + Type: MovementType.Supply, + DocumentType: "supply-reversal", + DocumentId: supply.Id, + DocumentNumber: supply.Number, + UnitCost: line.UnitPrice, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена проведения документа {supply.Number}"), ct); + } + + supply.Status = SupplyStatus.Draft; + supply.PostedAt = null; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) + { + var year = date.Year; + var prefix = $"П-{year}-"; + var lastNumber = await _db.Supplies + .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.Supplies.AsNoTracking() + join cp in _db.Counterparties on s.SupplierId equals cp.Id + 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, cp, st, cu }).FirstOrDefaultAsync(ct); + if (row is null) return null; + + var lines = await (from l in _db.SupplyLines.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.SupplyId == id + orderby l.SortOrder + select new SupplyLineDto( + l.Id, l.ProductId, p.Name, p.Article, u.Symbol, + l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder)) + .ToListAsync(ct); + + return new SupplyDto( + row.s.Id, row.s.Number, row.s.Date, row.s.Status, + row.cp.Id, row.cp.Name, + row.st.Id, row.st.Name, + row.cu.Id, row.cu.Code, + row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate, + row.s.Notes, + row.s.Total, row.s.PostedAt, + lines); + } +} diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs new file mode 100644 index 0000000..baf2ac4 --- /dev/null +++ b/src/food-market.domain/Purchases/Supply.cs @@ -0,0 +1,55 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Purchases; + +public enum SupplyStatus +{ + Draft = 0, + Posted = 1, +} + +public class Supply : TenantEntity +{ + /// Human-readable document number, unique per organization (e.g. "П-2026-000001"). + public string Number { get; set; } = ""; + + public DateTime Date { get; set; } = DateTime.UtcNow; + public SupplyStatus Status { get; set; } = SupplyStatus.Draft; + + public Guid SupplierId { get; set; } + public Counterparty Supplier { get; set; } = null!; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + public Guid CurrencyId { get; set; } + public Currency Currency { get; set; } = null!; + + public string? SupplierInvoiceNumber { get; set; } + public DateTime? SupplierInvoiceDate { get; set; } + public string? Notes { get; set; } + + /// Sum of line totals. Computed on save. + public decimal Total { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Lines { get; set; } = new List(); +} + +public class SupplyLine : TenantEntity +{ + public Guid SupplyId { get; set; } + public Supply Supply { 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 LineTotal { get; set; } + + public int SortOrder { get; set; } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index f5ed6e4..92fb05a 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -2,6 +2,7 @@ using foodmarket.Domain.Catalog; using foodmarket.Domain.Common; using foodmarket.Domain.Inventory; +using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Identity; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Persistence.Configurations; @@ -39,6 +40,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Stocks => Set(); public DbSet StockMovements => Set(); + public DbSet Supplies => Set(); + public DbSet SupplyLines => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -67,6 +71,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ConfigureCatalog(); builder.ConfigureInventory(); + builder.ConfigurePurchases(); // 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/PurchasesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs new file mode 100644 index 0000000..7b966d2 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs @@ -0,0 +1,42 @@ +using foodmarket.Domain.Purchases; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Persistence.Configurations; + +public static class PurchasesConfigurations +{ + public static void ConfigurePurchases(this ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("supplies"); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.SupplierInvoiceNumber).HasMaxLength(100); + e.Property(x => x.Notes).HasMaxLength(1000); + e.Property(x => x.Total).HasPrecision(18, 4); + + e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Lines).WithOne(l => l.Supply).HasForeignKey(l => l.SupplyId).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.SupplierId }); + }); + + b.Entity(e => + { + e.ToTable("supply_lines"); + e.Property(x => x.Quantity).HasPrecision(18, 4); + e.Property(x => x.UnitPrice).HasPrecision(18, 4); + e.Property(x => x.LineTotal).HasPrecision(18, 4); + + 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/20260421195644_Phase2b_Supply.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs new file mode 100644 index 0000000..afa0573 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs @@ -0,0 +1,1713 @@ +// +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("20260421195644_Phase2b_Supply")] + partial class Phase2b_Supply + { + /// + 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.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("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.cs b/src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.cs new file mode 100644 index 0000000..aab83ce --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.cs @@ -0,0 +1,171 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// + public partial class Phase2b_Supply : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "supplies", + 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), + SupplierId = table.Column(type: "uuid", nullable: false), + StoreId = table.Column(type: "uuid", nullable: false), + CurrencyId = table.Column(type: "uuid", nullable: false), + SupplierInvoiceNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + SupplierInvoiceDate = table.Column(type: "timestamp with time zone", nullable: true), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Total = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + 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_supplies", x => x.Id); + table.ForeignKey( + name: "FK_supplies_counterparties_SupplierId", + column: x => x.SupplierId, + principalSchema: "public", + principalTable: "counterparties", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_supplies_currencies_CurrencyId", + column: x => x.CurrencyId, + principalSchema: "public", + principalTable: "currencies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_supplies_stores_StoreId", + column: x => x.StoreId, + principalSchema: "public", + principalTable: "stores", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "supply_lines", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SupplyId = 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), + LineTotal = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, 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_supply_lines", x => x.Id); + table.ForeignKey( + name: "FK_supply_lines_products_ProductId", + column: x => x.ProductId, + principalSchema: "public", + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_supply_lines_supplies_SupplyId", + column: x => x.SupplyId, + principalSchema: "public", + principalTable: "supplies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_supplies_CurrencyId", + schema: "public", + table: "supplies", + column: "CurrencyId"); + + migrationBuilder.CreateIndex( + name: "IX_supplies_OrganizationId_Date", + schema: "public", + table: "supplies", + columns: new[] { "OrganizationId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_supplies_OrganizationId_Number", + schema: "public", + table: "supplies", + columns: new[] { "OrganizationId", "Number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_supplies_OrganizationId_Status", + schema: "public", + table: "supplies", + columns: new[] { "OrganizationId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_supplies_OrganizationId_SupplierId", + schema: "public", + table: "supplies", + columns: new[] { "OrganizationId", "SupplierId" }); + + migrationBuilder.CreateIndex( + name: "IX_supplies_StoreId", + schema: "public", + table: "supplies", + column: "StoreId"); + + migrationBuilder.CreateIndex( + name: "IX_supplies_SupplierId", + schema: "public", + table: "supplies", + column: "SupplierId"); + + migrationBuilder.CreateIndex( + name: "IX_supply_lines_OrganizationId_ProductId", + schema: "public", + table: "supply_lines", + columns: new[] { "OrganizationId", "ProductId" }); + + migrationBuilder.CreateIndex( + name: "IX_supply_lines_ProductId", + schema: "public", + table: "supply_lines", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_supply_lines_SupplyId", + schema: "public", + table: "supply_lines", + column: "SupplyId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "supply_lines", + schema: "public"); + + migrationBuilder.DropTable( + name: "supplies", + schema: "public"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 063f6d2..91289d4 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1150,6 +1150,129 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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.Infrastructure.Identity.Role", b => { b.Property("Id") @@ -1505,6 +1628,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => { b.Navigation("Authorizations"); @@ -1530,6 +1699,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Children"); }); + + modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", 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 ced480e..54f32ca 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -16,6 +16,8 @@ import { ProductEditPage } from '@/pages/ProductEditPage' import { MoySkladImportPage } from '@/pages/MoySkladImportPage' import { StockPage } from '@/pages/StockPage' import { StockMovementsPage } from '@/pages/StockMovementsPage' +import { SuppliesPage } from '@/pages/SuppliesPage' +import { SupplyEditPage } from '@/pages/SupplyEditPage' import { AppLayout } from '@/components/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -51,6 +53,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 1263a0c..466acc8 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, + Boxes, History, TruckIcon, } from 'lucide-react' import { Logo } from './Logo' @@ -40,6 +40,9 @@ const nav = [ { to: '/inventory/stock', icon: Boxes, label: 'Остатки' }, { to: '/inventory/movements', icon: History, label: 'Движения' }, ]}, + { group: 'Закупки', items: [ + { to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' }, + ]}, { group: 'Справочники', items: [ { to: '/catalog/countries', icon: Globe, label: 'Страны' }, { to: '/catalog/currencies', icon: Coins, label: 'Валюты' }, diff --git a/src/food-market.web/src/components/ProductPicker.tsx b/src/food-market.web/src/components/ProductPicker.tsx new file mode 100644 index 0000000..bbc626a --- /dev/null +++ b/src/food-market.web/src/components/ProductPicker.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Search, X } from 'lucide-react' +import { api } from '@/lib/api' +import type { PagedResult, Product } from '@/lib/types' + +interface Props { + open: boolean + onClose: () => void + onPick: (product: Product) => void + title?: string +} + +export function ProductPicker({ open, onClose, onPick, title = 'Выбор товара' }: Props) { + const [search, setSearch] = useState('') + + useEffect(() => { if (!open) setSearch('') }, [open]) + + const results = useQuery({ + queryKey: ['product-picker', search], + queryFn: async () => { + const params = new URLSearchParams({ pageSize: '30' }) + if (search) params.set('search', search) + return (await api.get>(`/api/catalog/products?${params}`)).data.items + }, + enabled: open, + }) + + if (!open) return null + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="По названию, артикулу или штрихкоду…" + className="w-full pl-9 pr-3 py-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]" + /> +
+
+ +
+ {results.isLoading &&
Загрузка…
} + {results.data && results.data.length === 0 && ( +
+ {search ? 'Ничего не найдено' : 'Начни вводить название или штрихкод'} +
+ )} + {results.data && results.data.map((p) => ( + + ))} +
+
+
+ ) +} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index c118a92..eb0c4ab 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -69,3 +69,31 @@ export interface MovementRow { type: string; documentType: string; documentId: string | null; documentNumber: string | null; notes: string | null; } + +export const SupplyStatus = { Draft: 0, Posted: 1 } as const +export type SupplyStatus = (typeof SupplyStatus)[keyof typeof SupplyStatus] + +export interface SupplyListRow { + id: string; number: string; date: string; status: SupplyStatus; + supplierId: string; supplierName: string; + storeId: string; storeName: string; + currencyId: string; currencyCode: string; + total: number; lineCount: number; postedAt: string | null; +} + +export interface SupplyLineDto { + id: string | null; productId: string; + productName: string | null; productArticle: string | null; unitSymbol: string | null; + quantity: number; unitPrice: number; lineTotal: number; sortOrder: number; +} + +export interface SupplyDto { + id: string; number: string; date: string; status: SupplyStatus; + supplierId: string; supplierName: string; + storeId: string; storeName: string; + currencyId: string; currencyCode: string; + supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null; + notes: string | null; + total: number; postedAt: string | null; + lines: SupplyLineDto[]; +} diff --git a/src/food-market.web/src/pages/SuppliesPage.tsx b/src/food-market.web/src/pages/SuppliesPage.tsx new file mode 100644 index 0000000..31d9d57 --- /dev/null +++ b/src/food-market.web/src/pages/SuppliesPage.tsx @@ -0,0 +1,55 @@ +import { Link, useNavigate } from 'react-router-dom' +import { Plus } from 'lucide-react' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' +import { Button } from '@/components/Button' +import { useCatalogList } from '@/lib/useCatalog' +import { type SupplyListRow, SupplyStatus } from '@/lib/types' + +const URL = '/api/purchases/supplies' + +export function SuppliesPage() { + const navigate = useNavigate() + const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + + return ( + + + + + + + } + footer={data && data.total > 0 && ( + + )} + > + r.id} + onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)} + columns={[ + { header: '№', width: '160px', cell: (r) => {r.number} }, + { header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, + { header: 'Статус', width: '130px', cell: (r) => ( + r.status === SupplyStatus.Posted + ? Проведён + : Черновик + )}, + { header: 'Поставщик', cell: (r) => r.supplierName }, + { header: 'Склад', width: '180px', cell: (r) => r.storeName }, + { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, + { header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` }, + ]} + empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения." + /> + + ) +} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx new file mode 100644 index 0000000..c26c002 --- /dev/null +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -0,0 +1,370 @@ +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 { SupplyStatus, type SupplyDto, type Product } from '@/lib/types' + +interface LineRow { + id?: string + productId: string + productName: string + productArticle: string | null + unitSymbol: string | null + quantity: number + unitPrice: number +} + +interface Form { + date: string + supplierId: string + storeId: string + currencyId: string + supplierInvoiceNumber: string + supplierInvoiceDate: string + notes: string + lines: LineRow[] +} + +const todayIso = () => new Date().toISOString().slice(0, 10) + +const emptyForm: Form = { + date: todayIso(), + supplierId: '', storeId: '', currencyId: '', + supplierInvoiceNumber: '', supplierInvoiceDate: '', + notes: '', + lines: [], +} + +export function SupplyEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const stores = useStores() + const currencies = useCurrencies() + const suppliers = useSuppliers() + + const [form, setForm] = useState
(emptyForm) + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState(null) + + const existing = useQuery({ + queryKey: ['/api/purchases/supplies', id], + queryFn: async () => (await api.get(`/api/purchases/supplies/${id}`)).data, + enabled: !isNew, + }) + + useEffect(() => { + if (!isNew && existing.data) { + const s = existing.data + setForm({ + date: s.date.slice(0, 10), + supplierId: s.supplierId, + storeId: s.storeId, + currencyId: s.currencyId, + supplierInvoiceNumber: s.supplierInvoiceNumber ?? '', + supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '', + 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, + })), + }) + } + }, [isNew, existing.data]) + + useEffect(() => { + // Prefill defaults for new document. + if (isNew) { + if (!form.storeId && stores.data?.length) { + const main = stores.data.find((s) => s.isMain) ?? stores.data[0] + setForm((f) => ({ ...f, storeId: main.id })) + } + if (!form.currencyId && currencies.data?.length) { + const kzt = currencies.data.find((c) => c.code === 'KZT') ?? currencies.data[0] + setForm((f) => ({ ...f, currencyId: kzt.id })) + } + if (!form.supplierId && suppliers.data?.length) { + setForm((f) => ({ ...f, supplierId: suppliers.data![0].id })) + } + } + }, [isNew, stores.data, currencies.data, suppliers.data, form.storeId, form.currencyId, form.supplierId]) + + const isDraft = isNew || existing.data?.status === SupplyStatus.Draft + const isPosted = existing.data?.status === SupplyStatus.Posted + + const lineTotal = (l: LineRow) => l.quantity * l.unitPrice + const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0) + + const save = useMutation({ + mutationFn: async () => { + const payload = { + date: new Date(form.date).toISOString(), + supplierId: form.supplierId, + storeId: form.storeId, + currencyId: form.currencyId, + supplierInvoiceNumber: form.supplierInvoiceNumber || null, + supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null, + notes: form.notes || null, + lines: form.lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })), + } + if (isNew) { + return (await api.post('/api/purchases/supplies', payload)).data + } + await api.put(`/api/purchases/supplies/${id}`, payload) + return null + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] }) + navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`) + }, + onError: (e: Error) => setError(e.message), + }) + + const post = useMutation({ + mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] }) + 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/purchases/supplies/${id}/unpost`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] }) + 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/purchases/supplies/${id}`) }, + onSuccess: () => navigate('/purchases/supplies'), + onError: (e: Error) => setError(e.message), + }) + + const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } + + const addLineFromProduct = (p: Product) => { + setForm({ + ...form, + lines: [...form.lines, { + productId: p.id, + productName: p.name, + productArticle: p.article, + unitSymbol: p.unitSymbol, + quantity: 1, + unitPrice: p.purchasePrice ?? 0, + }], + }) + } + 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.supplierId && !!form.storeId && !!form.currencyId && isDraft + + return ( + + {/* Sticky top bar */} +
+
+ + + +
+

+ {isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'} +

+

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

+
+
+
+ {isPosted && ( + + )} + {isDraft && !isNew && ( + + )} + {isDraft && ( + + )} + {isDraft && !isNew && ( + + )} +
+
+ + {/* Scrollable body */} +
+
+ {error && ( +
{error}
+ )} + +
+
+ + setForm({ ...form, date: e.target.value })} /> + + + + + + + + + + + + setForm({ ...form, supplierInvoiceNumber: e.target.value })} /> + + + setForm({ ...form, supplierInvoiceDate: e.target.value })} /> + + +