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).
OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.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/other-system/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).
- OtherSystem 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 447ac65 — sticky top bar, sticky
table header, only the body scrolls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
4.1 KiB
C#
105 lines
4.1 KiB
C#
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<ActionResult<PagedResult<StockRow>>> 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<StockRow> { 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<ActionResult<PagedResult<MovementRow>>> 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<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
|
}
|
|
}
|