food-market/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs
nurdotnet 50e3676d71 phase2a: stock foundation (Stock + StockMovement) + MoySklad counterparty import
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) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00

42 lines
1.7 KiB
C#

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