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>
This commit is contained in:
nurdotnet 2026-04-22 00:51:07 +05:00
parent d3aa13dcbf
commit 50e3676d71
21 changed files with 2593 additions and 109 deletions

View file

@ -48,4 +48,14 @@ public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody]
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
return result;
}
[HttpPost("import-counterparties")]
public async Task<ActionResult<MoySkladImportResult>> 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;
}
}

View file

@ -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<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 };
}
}

View file

@ -131,6 +131,9 @@
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();

View file

@ -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
{
/// <summary>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).</summary>
Task<decimal> ApplyMovementAsync(StockMovementDraft draft, CancellationToken ct = default);
Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default);
}

View file

@ -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; }
/// <summary>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).</summary>
public decimal Available => Quantity - ReservedQuantity;
}

View file

@ -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!;
/// <summary>Signed quantity: positive = receipt, negative = issue.</summary>
public decimal Quantity { get; set; }
/// <summary>Per-unit cost at the time of movement (optional). Used for cost rollup / P&amp;L.</summary>
public decimal? UnitCost { get; set; }
public MovementType Type { get; set; }
/// <summary>Source document discriminator, e.g. "supply", "retail-sale", "write-off", "enter", "transfer-out".</summary>
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, // корректировка по результату инвентаризации
}

View file

@ -82,6 +82,25 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
}
}
public async IAsyncEnumerable<MsCounterparty> 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<MsListResponse<MsCounterparty>>(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<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();

View file

@ -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<string>? Tags { get; set; }
}

View file

@ -35,6 +35,88 @@ public class MoySkladImportService
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> 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<string>? 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<string>();
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<MoySkladImportResult> ImportProductsAsync(
string token,
bool overwriteExisting,

View file

@ -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<decimal> 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<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default)
{
var last = 0m;
foreach (var d in drafts) last = await ApplyMovementAsync(d, ct);
return last;
}
}

View file

@ -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<AppDbContext> options, ITenantContext tenan
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
public DbSet<Stock> Stocks => Set<Stock>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
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())

View file

@ -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<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 });
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase2a_Stock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "stock_movements",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
Type = table.Column<int>(type: "integer", nullable: false),
DocumentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
DocumentId = table.Column<Guid>(type: "uuid", nullable: true),
DocumentNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
OccurredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ReservedQuantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "stock_movements",
schema: "public");
migrationBuilder.DropTable(
name: "stocks",
schema: "public");
}
}
}

View file

@ -993,6 +993,118 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("vat_rates", "public");
});
modelBuilder.Entity("foodmarket.Domain.Inventory.Stock", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("ReservedQuantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<DateTime?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<Guid?>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("DocumentNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DocumentType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<decimal?>("UnitCost")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("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<Guid>("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");

View file

@ -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() {
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
<Route path="/catalog/countries" element={<CountriesPage />} />
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
<Route path="/inventory/stock" element={<StockPage />} />
<Route path="/inventory/movements" element={<StockMovementsPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
</Route>
</Route>

View file

@ -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: 'Валюты' },

View file

@ -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;
}

View file

@ -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<TestResponse>('/api/admin/moysklad/test', { token })).data,
})
const run = useMutation({
const products = useMutation({
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
})
const counterparties = useMutation({
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
})
return (
<div className="p-6 max-w-3xl">
<PageHeader
title="Импорт из МойСклад"
description="Перенос товаров, групп и цен из учётной записи МойСклад в food-market."
/>
<div className="h-full overflow-auto">
<div className="p-6 max-w-3xl">
<PageHeader
title="Импорт из МойСклад"
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market."
/>
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
<div className="flex gap-2.5 items-start">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
<p><strong>Токен не сохраняется</strong> передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> Настройки аккаунта Сервисный аккаунт создать токен (read-only прав достаточно).</p>
<p>Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».</p>
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
<div className="flex gap-2.5 items-start">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
<p><strong>Токен не сохраняется</strong> передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> Настройки аккаунта Доступ к API создать токен.</p>
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
</div>
</div>
</div>
</section>
</section>
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Токен МойСклад (Bearer)">
<TextInput
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта"
autoComplete="off"
spellCheck={false}
/>
</Field>
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Токен МойСклад (Bearer)">
<TextInput
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта"
autoComplete="off"
spellCheck={false}
/>
</Field>
<div className="flex gap-2 items-center">
<Button
variant="secondary"
onClick={() => test.mutate()}
disabled={!token || test.isPending}
>
<KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button>
<div className="flex gap-3 items-center flex-wrap">
<Button
variant="secondary"
onClick={() => test.mutate()}
disabled={!token || test.isPending}
>
<KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button>
{test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div>
)}
{test.error && (
<div className="text-sm text-red-600">
{formatError(test.error)}
</div>
)}
</div>
{test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div>
)}
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
</div>
</section>
<div className="pt-2 border-t border-slate-200 dark:border-slate-800 space-y-3">
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2>
<Checkbox
label="Перезаписать существующие товары (по артикулу/штрихкоду)"
label="Перезаписать существующие записи (по артикулу/имени)"
checked={overwrite}
onChange={setOverwrite}
/>
<Button
onClick={() => run.mutate()}
disabled={!token || run.isPending}
>
<Download className="w-4 h-4" />
{run.isPending ? 'Импортирую… (может занять минуты)' : 'Импортировать товары'}
</Button>
</div>
</section>
{run.data && (
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" /> Импорт завершён
</h3>
<dl className="grid grid-cols-4 gap-3 text-sm">
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Всего получено</dt>
<dd className="text-xl font-semibold mt-1">{run.data.total}</dd>
</div>
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 p-3">
<dt className="text-xs text-green-700 dark:text-green-400 uppercase">Создано</dt>
<dd className="text-xl font-semibold mt-1 text-green-700 dark:text-green-400">{run.data.created}</dd>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Пропущено</dt>
<dd className="text-xl font-semibold mt-1">{run.data.skipped}</dd>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Групп создано</dt>
<dd className="text-xl font-semibold mt-1">{run.data.groupsCreated}</dd>
</div>
</dl>
{run.data.errors.length > 0 && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer">
Ошибок: {run.data.errors.length} (развернуть)
</summary>
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
{run.data.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
<div className="flex gap-3 flex-wrap">
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}>
<Package className="w-4 h-4" />
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
</Button>
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
<Download className="w-4 h-4" />
<Users className="w-4 h-4" />
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
</Button>
</div>
</section>
)}
{run.error && (
<div className="mt-5 p-3.5 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900 text-sm text-red-700 dark:text-red-300">
Ошибка: {formatError(run.error)}
</div>
)}
<ImportResult title="Товары" result={products} />
<ImportResult title="Контрагенты" result={counterparties} />
</div>
</div>
)
}
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
{result.data
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} импорт завершён</>
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} ошибка</>}
</h3>
{result.data && (
<>
<dl className="grid grid-cols-4 gap-3 text-sm">
<StatBox label="Всего получено" value={result.data.total} />
<StatBox label="Создано" value={result.data.created} accent="green" />
<StatBox label="Пропущено" value={result.data.skipped} />
<StatBox label="Групп создано" value={result.data.groupsCreated} />
</dl>
{result.data.errors.length > 0 && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer">
Ошибок: {result.data.errors.length} (развернуть)
</summary>
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
</>
)}
{result.error && (
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div>
)}
</section>
)
}
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 (
<div className={`rounded-lg ${bg} p-3`}>
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
</div>
)
}

View file

@ -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<string, string> = {
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<PagedResult<MovementRow>>(`/api/inventory/movements?${params}`)).data
},
placeholderData: (prev) => prev,
})
return (
<ListPageShell
title="Движения"
description={data ? `${data.total.toLocaleString('ru')} операций в журнале` : 'Журнал всех изменений остатков'}
actions={
<div className="w-52">
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
<option value="">Все склады</option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</div>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => 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) => (
<div>
<div className="font-medium">{r.productName}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ header: 'Количество', width: '140px', className: 'text-right font-mono', cell: (r) => (
<span className={r.quantity > 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}>
{r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })}
</span>
)},
{ header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? <span className="text-slate-500">{r.documentType} · {r.documentNumber}</span> : <span className="text-slate-400"></span> },
]}
empty="Движений ещё нет."
/>
</ListPageShell>
)
}

View file

@ -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<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
},
placeholderData: (prev) => prev,
})
return (
<ListPageShell
title="Остатки"
description={data ? `${data.total.toLocaleString('ru')} позиций${storeId ? ' на выбранном складе' : ' по всем складам'}` : 'Текущие остатки по складам'}
actions={
<div className="flex items-center gap-3">
<div className="w-52">
<Select value={storeId} onChange={(e) => { setStoreId(e.target.value); setPage(1) }}>
<option value="">Все склады</option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</div>
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
</div>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => `${r.productId}:${r.storeId}`}
columns={[
{ header: 'Товар', cell: (r) => (
<div>
<div className="font-medium">{r.productName}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ 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) => (
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
{r.available.toLocaleString('ru', { maximumFractionDigits: 3 })}
</span>
)},
]}
empty="Остатков нет. Они появятся после первой приёмки (Phase 2b)."
/>
</ListPageShell>
)
}