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:
parent
d3aa13dcbf
commit
50e3676d71
|
|
@ -48,4 +48,14 @@ public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody]
|
||||||
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
|
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
src/food-market.api/Controllers/Inventory/StockController.cs
Normal file
104
src/food-market.api/Controllers/Inventory/StockController.cs
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -131,6 +131,9 @@
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
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<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
|
|
|
||||||
27
src/food-market.application/Inventory/IStockService.cs
Normal file
27
src/food-market.application/Inventory/IStockService.cs
Normal 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);
|
||||||
|
}
|
||||||
22
src/food-market.domain/Inventory/Stock.cs
Normal file
22
src/food-market.domain/Inventory/Stock.cs
Normal 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;
|
||||||
|
}
|
||||||
49
src/food-market.domain/Inventory/StockMovement.cs
Normal file
49
src/food-market.domain/Inventory/StockMovement.cs
Normal 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&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, // корректировка по результату инвентаризации
|
||||||
|
}
|
||||||
|
|
@ -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)
|
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var all = new List<MsProductFolder>();
|
var all = new List<MsProductFolder>();
|
||||||
|
|
|
||||||
|
|
@ -125,3 +125,21 @@ public class MsCountry
|
||||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
[JsonPropertyName("externalCode")] public string? ExternalCode { 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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,88 @@ public class MoySkladImportService
|
||||||
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
=> _client.WhoAmIAsync(token, 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(
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
bool overwriteExisting,
|
bool overwriteExisting,
|
||||||
|
|
|
||||||
68
src/food-market.infrastructure/Inventory/StockService.cs
Normal file
68
src/food-market.infrastructure/Inventory/StockService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Domain.Common;
|
using foodmarket.Domain.Common;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Domain.Organizations;
|
using foodmarket.Domain.Organizations;
|
||||||
using foodmarket.Infrastructure.Persistence.Configurations;
|
using foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
@ -35,6 +36,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
|
public DbSet<ProductBarcode> ProductBarcodes => Set<ProductBarcode>();
|
||||||
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
|
public DbSet<ProductImage> ProductImages => Set<ProductImage>();
|
||||||
|
|
||||||
|
public DbSet<Stock> Stocks => Set<Stock>();
|
||||||
|
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
@ -62,6 +66,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.ConfigureCatalog();
|
builder.ConfigureCatalog();
|
||||||
|
builder.ConfigureInventory();
|
||||||
|
|
||||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
||||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1539
src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs
generated
Normal file
1539
src/food-market.infrastructure/Persistence/Migrations/20260421194521_Phase2a_Stock.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -993,6 +993,118 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.ToTable("vat_rates", "public");
|
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 =>
|
modelBuilder.Entity("foodmarket.Domain.Organizations.Organization", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1355,6 +1467,44 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Navigation("Store");
|
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 =>
|
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Authorizations");
|
b.Navigation("Authorizations");
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
||||||
import { ProductsPage } from '@/pages/ProductsPage'
|
import { ProductsPage } from '@/pages/ProductsPage'
|
||||||
import { ProductEditPage } from '@/pages/ProductEditPage'
|
import { ProductEditPage } from '@/pages/ProductEditPage'
|
||||||
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
||||||
|
import { StockPage } from '@/pages/StockPage'
|
||||||
|
import { StockMovementsPage } from '@/pages/StockMovementsPage'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
|
|
@ -47,6 +49,8 @@ export default function App() {
|
||||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||||
<Route path="/catalog/countries" element={<CountriesPage />} />
|
<Route path="/catalog/countries" element={<CountriesPage />} />
|
||||||
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
<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 path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||||
|
Boxes, History,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
|
@ -35,6 +36,10 @@ const nav = [
|
||||||
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
|
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
|
||||||
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
|
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
|
||||||
]},
|
]},
|
||||||
|
{ group: 'Остатки', items: [
|
||||||
|
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
|
||||||
|
{ to: '/inventory/movements', icon: History, label: 'Движения' },
|
||||||
|
]},
|
||||||
{ group: 'Справочники', items: [
|
{ group: 'Справочники', items: [
|
||||||
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
||||||
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,18 @@ export interface Product {
|
||||||
imageUrl: string | null; isActive: boolean;
|
imageUrl: string | null; isActive: boolean;
|
||||||
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||||
import { AlertCircle, CheckCircle, Download, KeyRound } from 'lucide-react'
|
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
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 body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
|
||||||
const detail = body?.error ?? body?.error_description ?? body?.title
|
const detail = body?.error ?? body?.error_description ?? body?.title
|
||||||
if (status === 404) {
|
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 === 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} — МойСклад недоступен, попробуй позже.`
|
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
|
||||||
return detail ? `${status ?? ''} ${detail}` : err.message
|
return detail ? `${status ?? ''} ${detail}` : err.message
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +26,7 @@ function formatError(err: unknown): string {
|
||||||
|
|
||||||
interface TestResponse { organization: string; inn?: string | null }
|
interface TestResponse { organization: string; inn?: string | null }
|
||||||
interface ImportResponse {
|
interface ImportResponse {
|
||||||
total: number
|
total: number; created: number; skipped: number; groupsCreated: number; errors: string[]
|
||||||
created: number
|
|
||||||
skipped: number
|
|
||||||
groupsCreated: number
|
|
||||||
errors: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MoySkladImportPage() {
|
export function MoySkladImportPage() {
|
||||||
|
|
@ -42,123 +38,138 @@ export function MoySkladImportPage() {
|
||||||
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data,
|
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,
|
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-3xl">
|
<div className="h-full overflow-auto">
|
||||||
<PageHeader
|
<div className="p-6 max-w-3xl">
|
||||||
title="Импорт из МойСклад"
|
<PageHeader
|
||||||
description="Перенос товаров, групп и цен из учётной записи МойСклад в food-market."
|
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">
|
<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">
|
<div className="flex gap-2.5 items-start">
|
||||||
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
<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">
|
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
|
||||||
<p><strong>Токен не сохраняется</strong> — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
|
<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>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> → Настройки аккаунта → Доступ к API → создать токен.</p>
|
||||||
<p>Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».</p>
|
<p>Рекомендуется отдельный сервисный аккаунт с правом только на чтение.</p>
|
||||||
|
</div>
|
||||||
</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">
|
<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)">
|
<Field label="Токен МойСклад (Bearer)">
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder="персональный токен или токен сервисного аккаунта"
|
placeholder="персональный токен или токен сервисного аккаунта"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => test.mutate()}
|
onClick={() => test.mutate()}
|
||||||
disabled={!token || test.isPending}
|
disabled={!token || test.isPending}
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{test.data && (
|
{test.data && (
|
||||||
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
|
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
Подключено: <strong>{test.data.organization}</strong>
|
Подключено: <strong>{test.data.organization}</strong>
|
||||||
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{test.error && (
|
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
|
||||||
<div className="text-sm text-red-600">
|
</div>
|
||||||
{formatError(test.error)}
|
</section>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
<Checkbox
|
||||||
label="Перезаписать существующие товары (по артикулу/штрихкоду)"
|
label="Перезаписать существующие записи (по артикулу/имени)"
|
||||||
checked={overwrite}
|
checked={overwrite}
|
||||||
onChange={setOverwrite}
|
onChange={setOverwrite}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="flex gap-3 flex-wrap">
|
||||||
onClick={() => run.mutate()}
|
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}>
|
||||||
disabled={!token || run.isPending}
|
<Package className="w-4 h-4" />
|
||||||
>
|
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
|
||||||
<Download className="w-4 h-4" />
|
</Button>
|
||||||
{run.isPending ? 'Импортирую… (может занять минуты)' : 'Импортировать товары'}
|
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
|
||||||
</Button>
|
<Download className="w-4 h-4" />
|
||||||
</div>
|
<Users className="w-4 h-4" />
|
||||||
</section>
|
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
|
||||||
|
</Button>
|
||||||
{run.data && (
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{run.error && (
|
<ImportResult title="Товары" result={products} />
|
||||||
<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">
|
<ImportResult title="Контрагенты" result={counterparties} />
|
||||||
Ошибка: {formatError(run.error)}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/food-market.web/src/pages/StockMovementsPage.tsx
Normal file
81
src/food-market.web/src/pages/StockMovementsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/food-market.web/src/pages/StockPage.tsx
Normal file
76
src/food-market.web/src/pages/StockPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue