From 50e3676d71df64a3948cf0b1794f7489363c2d47 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:51:07 +0500 Subject: [PATCH] phase2a: stock foundation (Stock + StockMovement) + MoySklad counterparty import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain: - foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store) with Quantity, ReservedQuantity, computed Available. Unique index on tenant+ product+store. - foodmarket.Domain.Inventory.StockMovement — append-only journal with signed quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale, WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn, WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number), OccurredAt, CreatedBy, Notes. Application: - IStockService.ApplyMovementAsync draft — appends movement row + upserts materialized Stock row in the same unit of work. Callers control SaveChanges so a posting doc can bundle all lines atomically. Infrastructure: - StockService implementation over AppDbContext. - InventoryConfigurations EF mapping (precision 18,4 on quantities/costs; indexes for product+time, store+time, document lookup). - Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements). API (GET, read-only for now): - /api/inventory/stock — filter by store, product, includeZero; joins product + unit + store names; server-side pagination. - /api/inventory/movements — journal filtered by store/product/date range; movement type as string enum for UI labels. - Both [Authorize] (any authenticated user). MoySklad: - MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...). - MoySkladClient.StreamCounterpartiesAsync — paginated like products. - MoySkladImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier / customer / both), companyType → LegalEntity/Individual; dedup by Name; defensive trim on all string fields; per-item try/catch; batches of 500. - /api/admin/moysklad/import-counterparties endpoint (Admin policy). Web: - /inventory/stock list page (store filter, include-zero toggle, search; shows quantity/reserved/available with red-on-negative, grey-on-zero accents). - /inventory/movements list page (store filter; colored quantity +/-, Russian labels for each movement type). - MoySklad import page restructured: single token test + two import buttons (Товары, Контрагенты) + reusable ImportResult panel that handles both. - Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History. Uses the ListPageShell pattern introduced in d3aa13d — sticky top bar, sticky table header, only the body scrolls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Admin/MoySkladImportController.cs | 10 + .../Controllers/Inventory/StockController.cs | 104 ++ src/food-market.api/Program.cs | 3 + .../Inventory/IStockService.cs | 27 + src/food-market.domain/Inventory/Stock.cs | 22 + .../Inventory/StockMovement.cs | 49 + .../Integrations/MoySklad/MoySkladClient.cs | 19 + .../Integrations/MoySklad/MoySkladDtos.cs | 18 + .../MoySklad/MoySkladImportService.cs | 82 + .../Inventory/StockService.cs | 68 + .../Persistence/AppDbContext.cs | 5 + .../Configurations/InventoryConfigurations.cs | 41 + .../20260421194521_Phase2a_Stock.Designer.cs | 1539 +++++++++++++++++ .../20260421194521_Phase2a_Stock.cs | 155 ++ .../Migrations/AppDbContextModelSnapshot.cs | 150 ++ src/food-market.web/src/App.tsx | 4 + .../src/components/AppLayout.tsx | 5 + src/food-market.web/src/lib/types.ts | 15 + .../src/pages/MoySkladImportPage.tsx | 229 +-- .../src/pages/StockMovementsPage.tsx | 81 + src/food-market.web/src/pages/StockPage.tsx | 76 + 21 files changed, 2593 insertions(+), 109 deletions(-) create mode 100644 src/food-market.api/Controllers/Inventory/StockController.cs create mode 100644 src/food-market.application/Inventory/IStockService.cs create mode 100644 src/food-market.domain/Inventory/Stock.cs create mode 100644 src/food-market.domain/Inventory/StockMovement.cs create mode 100644 src/food-market.infrastructure/Inventory/StockService.cs create mode 100644 src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.cs create mode 100644 src/food-market.web/src/pages/StockMovementsPage.tsx create mode 100644 src/food-market.web/src/pages/StockPage.tsx diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index c23d150..591b214 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -48,4 +48,14 @@ public async Task> ImportProducts([FromBody] var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct); return result; } + + [HttpPost("import-counterparties")] + public async Task> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Token)) + return BadRequest(new { error = "Token is required." }); + + var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct); + return result; + } } diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs new file mode 100644 index 0000000..6cc550b --- /dev/null +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -0,0 +1,104 @@ +using foodmarket.Application.Common; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Inventory; + +[ApiController] +[Authorize] +[Route("api/inventory")] +public class StockController : ControllerBase +{ + private readonly AppDbContext _db; + public StockController(AppDbContext db) => _db = db; + + public record StockRow( + Guid ProductId, string ProductName, string? Article, string UnitSymbol, + Guid StoreId, string StoreName, + decimal Quantity, decimal ReservedQuantity, decimal Available); + + [HttpGet("stock")] + public async Task>> GetStock( + [FromQuery] Guid? storeId, + [FromQuery] Guid? productId, + [FromQuery] string? search, + [FromQuery] bool includeZero = false, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken ct = default) + { + var q = from s in _db.Stocks + join p in _db.Products on s.ProductId equals p.Id + join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id + join st in _db.Stores on s.StoreId equals st.Id + select new { s, p, u, st }; + + if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value); + if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value); + if (!includeZero) q = q.Where(x => x.s.Quantity != 0); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = $"%{search.Trim()}%"; + q = q.Where(x => EF.Functions.ILike(x.p.Name, term) + || (x.p.Article != null && EF.Functions.ILike(x.p.Article, term))); + } + + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(x => x.p.Name) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new StockRow( + x.p.Id, x.p.Name, x.p.Article, x.u.Symbol, + x.st.Id, x.st.Name, + x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) + .ToListAsync(ct); + + return new PagedResult { Items = items, Total = total, Page = page, PageSize = pageSize }; + } + + public record MovementRow( + Guid Id, DateTime OccurredAt, + Guid ProductId, string ProductName, string? Article, + Guid StoreId, string StoreName, + decimal Quantity, decimal? UnitCost, + string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber, + string? Notes); + + [HttpGet("movements")] + public async Task>> GetMovements( + [FromQuery] Guid? storeId, + [FromQuery] Guid? productId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken ct = default) + { + var q = from m in _db.StockMovements + join p in _db.Products on m.ProductId equals p.Id + join st in _db.Stores on m.StoreId equals st.Id + select new { m, p, st }; + + if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value); + if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value); + if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value); + if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value); + + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(x => x.m.OccurredAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new MovementRow( + x.m.Id, x.m.OccurredAt, + x.p.Id, x.p.Name, x.p.Article, + x.st.Id, x.st.Name, + x.m.Quantity, x.m.UnitCost, + x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber, + x.m.Notes)) + .ToListAsync(ct); + + return new PagedResult { Items = items, Total = total, Page = page, PageSize = pageSize }; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 940d69b..7a19a34 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -131,6 +131,9 @@ }); builder.Services.AddScoped(); + // Inventory + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/food-market.application/Inventory/IStockService.cs b/src/food-market.application/Inventory/IStockService.cs new file mode 100644 index 0000000..550f821 --- /dev/null +++ b/src/food-market.application/Inventory/IStockService.cs @@ -0,0 +1,27 @@ +using foodmarket.Domain.Inventory; + +namespace foodmarket.Application.Inventory; + +public record StockMovementDraft( + Guid ProductId, + Guid StoreId, + decimal Quantity, + MovementType Type, + string DocumentType, + Guid? DocumentId = null, + string? DocumentNumber = null, + decimal? UnitCost = null, + DateTime? OccurredAt = null, + Guid? CreatedByUserId = null, + string? Notes = null); + +public interface IStockService +{ + /// Writes the movement + updates the materialized Stock row in a single unit of work. + /// Returns the new on-hand quantity. Callers must commit the DbContext themselves (we don't + /// wrap in a transaction — typical flow is as part of a document posting that already bundles + /// multiple movements into one SaveChanges). + Task ApplyMovementAsync(StockMovementDraft draft, CancellationToken ct = default); + + Task ApplyMovementsAsync(IEnumerable drafts, CancellationToken ct = default); +} diff --git a/src/food-market.domain/Inventory/Stock.cs b/src/food-market.domain/Inventory/Stock.cs new file mode 100644 index 0000000..e5ab4ef --- /dev/null +++ b/src/food-market.domain/Inventory/Stock.cs @@ -0,0 +1,22 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Inventory; + +// Materialized current-stock aggregate per (Product, Store). Kept in sync with StockMovement +// inserts by IStockService — never write to this entity directly. +public class Stock : TenantEntity +{ + public Guid ProductId { get; set; } + public Product Product { get; set; } = null!; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + public decimal Quantity { get; set; } + public decimal ReservedQuantity { get; set; } + + /// Available = on-hand − reserved. Cannot be negative in normal flow; a negative + /// value indicates the business allowed overselling (e.g., retail sale before physical receipt). + public decimal Available => Quantity - ReservedQuantity; +} diff --git a/src/food-market.domain/Inventory/StockMovement.cs b/src/food-market.domain/Inventory/StockMovement.cs new file mode 100644 index 0000000..0bbd345 --- /dev/null +++ b/src/food-market.domain/Inventory/StockMovement.cs @@ -0,0 +1,49 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Inventory; + +// Immutable, append-only journal of every stock change. +// Stock table is a materialized aggregate over this journal. +public class StockMovement : TenantEntity +{ + public Guid ProductId { get; set; } + public Product Product { get; set; } = null!; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + /// Signed quantity: positive = receipt, negative = issue. + public decimal Quantity { get; set; } + + /// Per-unit cost at the time of movement (optional). Used for cost rollup / P&L. + public decimal? UnitCost { get; set; } + + public MovementType Type { get; set; } + + /// Source document discriminator, e.g. "supply", "retail-sale", "write-off", "enter", "transfer-out". + public string DocumentType { get; set; } = ""; + + public Guid? DocumentId { get; set; } + public string? DocumentNumber { get; set; } + + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; + + public Guid? CreatedByUserId { get; set; } + public string? Notes { get; set; } +} + +public enum MovementType +{ + Initial = 0, + Supply = 1, // приёмка от поставщика + RetailSale = 2, // розничная продажа + WholesaleSale = 3, // оптовая отгрузка + CustomerReturn = 4, // возврат покупателя + SupplierReturn = 5, // возврат поставщику + TransferOut = 6, // перемещение со склада + TransferIn = 7, // перемещение на склад + WriteOff = 8, // списание + Enter = 9, // оприходование + InventoryAdjustment = 10, // корректировка по результату инвентаризации +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index b5497ea..618a589 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -82,6 +82,25 @@ public async Task> WhoAmIAsync(string token, C } } + public async IAsyncEnumerable StreamCounterpartiesAsync( + string token, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + const int pageSize = 1000; + var offset = 0; + while (true) + { + using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token); + using var res = await _http.SendAsync(req, ct); + res.EnsureSuccessStatusCode(); + var page = await res.Content.ReadFromJsonAsync>(Json, ct); + if (page is null || page.Rows.Count == 0) yield break; + foreach (var c in page.Rows) yield return c; + if (page.Rows.Count < pageSize) yield break; + offset += pageSize; + } + } + public async Task> GetAllFoldersAsync(string token, CancellationToken ct) { var all = new List(); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs index c4f47b8..693529b 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs @@ -125,3 +125,21 @@ public class MsCountry [JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("externalCode")] public string? ExternalCode { get; set; } } + +public class MsCounterparty +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("legalTitle")] public string? LegalTitle { get; set; } + [JsonPropertyName("legalAddress")] public string? LegalAddress { get; set; } + [JsonPropertyName("actualAddress")] public string? ActualAddress { get; set; } + [JsonPropertyName("inn")] public string? Inn { get; set; } + [JsonPropertyName("kpp")] public string? Kpp { get; set; } + [JsonPropertyName("ogrn")] public string? Ogrn { get; set; } + [JsonPropertyName("companyType")] public string? CompanyType { get; set; } + [JsonPropertyName("phone")] public string? Phone { get; set; } + [JsonPropertyName("email")] public string? Email { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("archived")] public bool Archived { get; set; } + [JsonPropertyName("tags")] public List? Tags { get; set; } +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 223ff5e..58ef210 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -35,6 +35,88 @@ public class MoySkladImportService public Task> TestConnectionAsync(string token, CancellationToken ct) => _client.WhoAmIAsync(token, ct); + public async Task ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); + + // Map MoySklad tag set → local CounterpartyKind. If no tags say otherwise, assume Both. + static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList? tags) + { + if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Both; + var lower = tags.Select(t => t.ToLowerInvariant()).ToList(); + var hasSupplier = lower.Any(t => t.Contains("постав")); + var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент")); + if (hasSupplier && !hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier; + if (hasCustomer && !hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Customer; + return foodmarket.Domain.Catalog.CounterpartyKind.Both; + } + + static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) + => companyType switch + { + "individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual, + _ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity, + }; + + var existingByName = await _db.Counterparties + .Select(c => new { c.Id, c.Name }) + .ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct); + + var created = 0; + var skipped = 0; + var total = 0; + var errors = new List(); + var batch = 0; + + await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) + { + total++; + if (c.Archived) { skipped++; continue; } + + if (existingByName.ContainsKey(c.Name) && !overwriteExisting) + { + skipped++; + continue; + } + + try + { + var entity = new foodmarket.Domain.Catalog.Counterparty + { + OrganizationId = orgId, + Name = Trim(c.Name, 255) ?? c.Name, + LegalName = Trim(c.LegalTitle, 500), + Kind = ResolveKind(c.Tags), + Type = ResolveType(c.CompanyType), + Bin = Trim(c.Inn, 20), + TaxNumber = Trim(c.Kpp, 20), + Phone = Trim(c.Phone, 50), + Email = Trim(c.Email, 255), + Address = Trim(c.ActualAddress ?? c.LegalAddress, 500), + Notes = Trim(c.Description, 1000), + IsActive = !c.Archived, + }; + _db.Counterparties.Add(entity); + existingByName[c.Name] = entity.Id; + created++; + batch++; + if (batch >= 500) + { + await _db.SaveChangesAsync(ct); + batch = 0; + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name); + errors.Add($"{c.Name}: {ex.Message}"); + } + } + + if (batch > 0) await _db.SaveChangesAsync(ct); + return new MoySkladImportResult(total, created, skipped, 0, errors); + } + public async Task ImportProductsAsync( string token, bool overwriteExisting, diff --git a/src/food-market.infrastructure/Inventory/StockService.cs b/src/food-market.infrastructure/Inventory/StockService.cs new file mode 100644 index 0000000..2f6afbc --- /dev/null +++ b/src/food-market.infrastructure/Inventory/StockService.cs @@ -0,0 +1,68 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Inventory; + +public class StockService : IStockService +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public StockService(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set."); + + _db.StockMovements.Add(new StockMovement + { + OrganizationId = orgId, + ProductId = d.ProductId, + StoreId = d.StoreId, + Quantity = d.Quantity, + UnitCost = d.UnitCost, + Type = d.Type, + DocumentType = d.DocumentType, + DocumentId = d.DocumentId, + DocumentNumber = d.DocumentNumber, + OccurredAt = d.OccurredAt ?? DateTime.UtcNow, + CreatedByUserId = d.CreatedByUserId, + Notes = d.Notes, + }); + + var stock = await _db.Stocks.FirstOrDefaultAsync( + s => s.ProductId == d.ProductId && s.StoreId == d.StoreId, ct); + + if (stock is null) + { + stock = new Stock + { + OrganizationId = orgId, + ProductId = d.ProductId, + StoreId = d.StoreId, + Quantity = d.Quantity, + }; + _db.Stocks.Add(stock); + } + else + { + stock.Quantity += d.Quantity; + } + + return stock.Quantity; + } + + public async Task ApplyMovementsAsync(IEnumerable drafts, CancellationToken ct = default) + { + var last = 0m; + foreach (var d in drafts) last = await ApplyMovementAsync(d, ct); + return last; + } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 77c1fae..f5ed6e4 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -1,6 +1,7 @@ using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Domain.Common; +using foodmarket.Domain.Inventory; using foodmarket.Infrastructure.Identity; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Persistence.Configurations; @@ -35,6 +36,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet ProductBarcodes => Set(); public DbSet ProductImages => Set(); + public DbSet Stocks => Set(); + public DbSet StockMovements => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -62,6 +66,7 @@ protected override void OnModelCreating(ModelBuilder builder) }); builder.ConfigureCatalog(); + builder.ConfigureInventory(); // 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/InventoryConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs new file mode 100644 index 0000000..5b491ab --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs @@ -0,0 +1,41 @@ +using foodmarket.Domain.Inventory; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Persistence.Configurations; + +public static class InventoryConfigurations +{ + public static void ConfigureInventory(this ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("stocks"); + e.Property(x => x.Quantity).HasPrecision(18, 4); + e.Property(x => x.ReservedQuantity).HasPrecision(18, 4); + e.Ignore(x => x.Available); + + e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.StoreId }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.StoreId }); + }); + + b.Entity(e => + { + e.ToTable("stock_movements"); + e.Property(x => x.Quantity).HasPrecision(18, 4); + e.Property(x => x.UnitCost).HasPrecision(18, 4); + e.Property(x => x.DocumentType).HasMaxLength(50).IsRequired(); + e.Property(x => x.DocumentNumber).HasMaxLength(50); + e.Property(x => x.Notes).HasMaxLength(500); + + e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + + e.HasIndex(x => new { x.OrganizationId, x.ProductId, x.OccurredAt }); + e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.OccurredAt }); + e.HasIndex(x => new { x.DocumentType, x.DocumentId }); + }); + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs b/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs new file mode 100644 index 0000000..d2ef049 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs @@ -0,0 +1,1539 @@ +// +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("20260421194521_Phase2a_Stock")] + partial class Phase2a_Stock + { + /// + 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.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("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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.cs b/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.cs new file mode 100644 index 0000000..abbe555 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// + public partial class Phase2a_Stock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "stock_movements", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + StoreId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true), + Type = table.Column(type: "integer", nullable: false), + DocumentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + DocumentId = table.Column(type: "uuid", nullable: true), + DocumentNumber = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedByUserId = table.Column(type: "uuid", nullable: true), + Notes = table.Column(type: "character varying(500)", maxLength: 500, 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_stock_movements", x => x.Id); + table.ForeignKey( + name: "FK_stock_movements_products_ProductId", + column: x => x.ProductId, + principalSchema: "public", + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_stock_movements_stores_StoreId", + column: x => x.StoreId, + principalSchema: "public", + principalTable: "stores", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "stocks", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProductId = table.Column(type: "uuid", nullable: false), + StoreId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + ReservedQuantity = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, 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_stocks", x => x.Id); + table.ForeignKey( + name: "FK_stocks_products_ProductId", + column: x => x.ProductId, + principalSchema: "public", + principalTable: "products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_stocks_stores_StoreId", + column: x => x.StoreId, + principalSchema: "public", + principalTable: "stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_stock_movements_DocumentType_DocumentId", + schema: "public", + table: "stock_movements", + columns: new[] { "DocumentType", "DocumentId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_movements_OrganizationId_ProductId_OccurredAt", + schema: "public", + table: "stock_movements", + columns: new[] { "OrganizationId", "ProductId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_movements_OrganizationId_StoreId_OccurredAt", + schema: "public", + table: "stock_movements", + columns: new[] { "OrganizationId", "StoreId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_movements_ProductId", + schema: "public", + table: "stock_movements", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_movements_StoreId", + schema: "public", + table: "stock_movements", + column: "StoreId"); + + migrationBuilder.CreateIndex( + name: "IX_stocks_OrganizationId_ProductId_StoreId", + schema: "public", + table: "stocks", + columns: new[] { "OrganizationId", "ProductId", "StoreId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stocks_OrganizationId_StoreId", + schema: "public", + table: "stocks", + columns: new[] { "OrganizationId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_stocks_ProductId", + schema: "public", + table: "stocks", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_stocks_StoreId", + schema: "public", + table: "stocks", + column: "StoreId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stock_movements", + schema: "public"); + + migrationBuilder.DropTable( + name: "stocks", + schema: "public"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 9113ff0..063f6d2 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -993,6 +993,118 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1355,6 +1467,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => { b.Navigation("Authorizations"); diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 71dea16..ced480e 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -14,6 +14,8 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { ProductsPage } from '@/pages/ProductsPage' import { ProductEditPage } from '@/pages/ProductEditPage' import { MoySkladImportPage } from '@/pages/MoySkladImportPage' +import { StockPage } from '@/pages/StockPage' +import { StockMovementsPage } from '@/pages/StockMovementsPage' import { AppLayout } from '@/components/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -47,6 +49,8 @@ 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 78a41f3..1263a0c 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -6,6 +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, } from 'lucide-react' import { Logo } from './Logo' @@ -35,6 +36,10 @@ const nav = [ { to: '/catalog/stores', icon: Warehouse, label: 'Склады' }, { to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' }, ]}, + { group: 'Остатки', items: [ + { to: '/inventory/stock', icon: Boxes, label: 'Остатки' }, + { to: '/inventory/movements', icon: History, 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 17e1f55..c118a92 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -54,3 +54,18 @@ export interface Product { imageUrl: string | null; isActive: boolean; prices: ProductPrice[]; barcodes: ProductBarcode[] } + +export interface StockRow { + productId: string; productName: string; article: string | null; unitSymbol: string; + storeId: string; storeName: string; + quantity: number; reservedQuantity: number; available: number; +} + +export interface MovementRow { + id: string; occurredAt: string; + productId: string; productName: string; article: string | null; + storeId: string; storeName: string; + quantity: number; unitCost: number | null; + type: string; documentType: string; documentId: string | null; documentNumber: string | null; + notes: string | null; +} diff --git a/src/food-market.web/src/pages/MoySkladImportPage.tsx b/src/food-market.web/src/pages/MoySkladImportPage.tsx index b9caeff..4f49f08 100644 --- a/src/food-market.web/src/pages/MoySkladImportPage.tsx +++ b/src/food-market.web/src/pages/MoySkladImportPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { AlertCircle, CheckCircle, Download, KeyRound } from 'lucide-react' +import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query' +import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react' import { AxiosError } from 'axios' import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' @@ -13,10 +13,10 @@ function formatError(err: unknown): string { const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined const detail = body?.error ?? body?.error_description ?? body?.title if (status === 404) { - return `404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull. Сделай Ctrl+C → dotnet run.` + return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.' } if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.' - if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin для этой операции.' + if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.' if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.` return detail ? `${status ?? ''} ${detail}` : err.message } @@ -26,11 +26,7 @@ function formatError(err: unknown): string { interface TestResponse { organization: string; inn?: string | null } interface ImportResponse { - total: number - created: number - skipped: number - groupsCreated: number - errors: string[] + total: number; created: number; skipped: number; groupsCreated: number; errors: string[] } export function MoySkladImportPage() { @@ -42,123 +38,138 @@ export function MoySkladImportPage() { mutationFn: async () => (await api.post('/api/admin/moysklad/test', { token })).data, }) - const run = useMutation({ + const products = useMutation({ mutationFn: async () => (await api.post('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data, onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }), }) + const counterparties = useMutation({ + mutationFn: async () => (await api.post('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }), + }) + return ( -
- +
+
+ -
-
- -
-

Токен не сохраняется — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.

-

Получить токен: online.moysklad.ru/app → Настройки аккаунта → Сервисный аккаунт → создать токен (read-only прав достаточно).

-

Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».

+
+
+ +
+

Токен не сохраняется — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.

+

Получить токен: online.moysklad.ru/app → Настройки аккаунта → Доступ к API → создать токен.

+

Рекомендуется отдельный сервисный аккаунт с правом только на чтение.

+
-
-
+ -
- - setToken(e.target.value)} - placeholder="персональный токен или токен сервисного аккаунта" - autoComplete="off" - spellCheck={false} - /> - +
+ + setToken(e.target.value)} + placeholder="персональный токен или токен сервисного аккаунта" + autoComplete="off" + spellCheck={false} + /> + -
- +
+ - {test.data && ( -
- - Подключено: {test.data.organization} - {test.data.inn && (ИНН {test.data.inn})} -
- )} - {test.error && ( -
- {formatError(test.error)} -
- )} -
+ {test.data && ( +
+ + Подключено: {test.data.organization} + {test.data.inn && (ИНН {test.data.inn})} +
+ )} + {test.error &&
{formatError(test.error)}
} +
+
-
+
+

Операции импорта

- -
-
- - {run.data && ( -
-

- Импорт завершён -

-
-
-
Всего получено
-
{run.data.total}
-
-
-
Создано
-
{run.data.created}
-
-
-
Пропущено
-
{run.data.skipped}
-
-
-
Групп создано
-
{run.data.groupsCreated}
-
-
- - {run.data.errors.length > 0 && ( -
- - Ошибок: {run.data.errors.length} (развернуть) - -
    - {run.data.errors.map((e, i) =>
  • {e}
  • )} -
-
- )} +
+ + +
- )} - {run.error && ( -
- Ошибка: {formatError(run.error)} -
- )} + + +
+
+ ) +} + +function ImportResult({ title, result }: { title: string; result: UseMutationResult }) { + if (!result.data && !result.error) return null + return ( +
+

+ {result.data + ? <> {title} — импорт завершён + : <> {title} — ошибка} +

+ {result.data && ( + <> +
+ + + + +
+ {result.data.errors.length > 0 && ( +
+ + Ошибок: {result.data.errors.length} (развернуть) + +
    + {result.data.errors.map((e, i) =>
  • {e}
  • )} +
+
+ )} + + )} + {result.error && ( +
{formatError(result.error)}
+ )} +
+ ) +} + +function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) { + const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50' + const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : '' + return ( +
+
{label}
+
{value.toLocaleString('ru')}
) } diff --git a/src/food-market.web/src/pages/StockMovementsPage.tsx b/src/food-market.web/src/pages/StockMovementsPage.tsx new file mode 100644 index 0000000..ef9d0de --- /dev/null +++ b/src/food-market.web/src/pages/StockMovementsPage.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { Select } from '@/components/Field' +import { api } from '@/lib/api' +import { useStores } from '@/lib/useLookups' +import type { PagedResult, MovementRow } from '@/lib/types' + +const typeLabels: Record = { + Initial: 'Начальный', + Supply: 'Приёмка', + RetailSale: 'Розн. продажа', + WholesaleSale: 'Опт. продажа', + CustomerReturn: 'Возврат покуп.', + SupplierReturn: 'Возврат пост.', + TransferOut: 'Перемещ. из', + TransferIn: 'Перемещ. в', + WriteOff: 'Списание', + Enter: 'Оприходование', + InventoryAdjustment: 'Инвентаризация', +} + +export function StockMovementsPage() { + const stores = useStores() + const [storeId, setStoreId] = useState('') + const [page, setPage] = useState(1) + + const { data, isLoading } = useQuery({ + queryKey: ['/api/inventory/movements', { storeId, page }], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), pageSize: '50' }) + if (storeId) params.set('storeId', storeId) + return (await api.get>(`/api/inventory/movements?${params}`)).data + }, + placeholderData: (prev) => prev, + }) + + return ( + + +
+ } + footer={data && data.total > 0 && ( + + )} + > + r.id} + columns={[ + { header: 'Дата', width: '160px', cell: (r) => new Date(r.occurredAt).toLocaleString('ru') }, + { header: 'Операция', width: '160px', cell: (r) => typeLabels[r.type] ?? r.type }, + { header: 'Товар', cell: (r) => ( +
+
{r.productName}
+ {r.article &&
{r.article}
} +
+ )}, + { header: 'Склад', width: '180px', cell: (r) => r.storeName }, + { header: 'Количество', width: '140px', className: 'text-right font-mono', cell: (r) => ( + 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}> + {r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })} + + )}, + { header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? {r.documentType} · {r.documentNumber} : }, + ]} + empty="Движений ещё нет." + /> + + ) +} diff --git a/src/food-market.web/src/pages/StockPage.tsx b/src/food-market.web/src/pages/StockPage.tsx new file mode 100644 index 0000000..b6a5ae1 --- /dev/null +++ b/src/food-market.web/src/pages/StockPage.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' +import { Select, Checkbox } from '@/components/Field' +import { api } from '@/lib/api' +import { useStores } from '@/lib/useLookups' +import type { PagedResult, StockRow } from '@/lib/types' + +export function StockPage() { + const stores = useStores() + const [storeId, setStoreId] = useState('') + const [search, setSearch] = useState('') + const [includeZero, setIncludeZero] = useState(false) + const [page, setPage] = useState(1) + + const { data, isLoading } = useQuery({ + queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page }], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), pageSize: '50' }) + if (storeId) params.set('storeId', storeId) + if (search) params.set('search', search) + if (includeZero) params.set('includeZero', 'true') + return (await api.get>(`/api/inventory/stock?${params}`)).data + }, + placeholderData: (prev) => prev, + }) + + return ( + +
+ +
+ { setIncludeZero(v); setPage(1) }} /> + { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" /> + + } + footer={data && data.total > 0 && ( + + )} + > + `${r.productId}:${r.storeId}`} + columns={[ + { header: 'Товар', cell: (r) => ( +
+
{r.productName}
+ {r.article &&
{r.article}
} +
+ )}, + { header: 'Склад', width: '220px', cell: (r) => r.storeName }, + { header: 'Ед.', width: '80px', cell: (r) => r.unitSymbol }, + { header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) }, + { header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' }, + { header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => ( + + {r.available.toLocaleString('ru', { maximumFractionDigits: 3 })} + + )}, + ]} + empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)." + /> +
+ ) +}