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>
62 lines
2.4 KiB
C#
62 lines
2.4 KiB
C#
using foodmarket.Infrastructure.Integrations.MoySklad;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace foodmarket.Api.Controllers.Admin;
|
|
|
|
[ApiController]
|
|
[Authorize(Policy = "AdminAccess")]
|
|
[Route("api/admin/moysklad")]
|
|
public class MoySkladImportController : ControllerBase
|
|
{
|
|
private readonly MoySkladImportService _svc;
|
|
|
|
public MoySkladImportController(MoySkladImportService svc) => _svc = svc;
|
|
|
|
public record TestRequest(string Token);
|
|
public record ImportRequest(string Token, bool OverwriteExisting = false);
|
|
|
|
[HttpPost("test")]
|
|
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.Token))
|
|
return BadRequest(new { error = "Token is required." });
|
|
|
|
var result = await _svc.TestConnectionAsync(req.Token, ct);
|
|
if (!result.Success)
|
|
{
|
|
var msg = result.StatusCode switch
|
|
{
|
|
401 or 403 => "Токен недействителен или не имеет доступа к API.",
|
|
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
|
|
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
|
|
};
|
|
return StatusCode(result.StatusCode ?? 502, new { error = msg });
|
|
}
|
|
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
|
|
}
|
|
|
|
private static string? Truncate(string? s, int max)
|
|
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
|
|
|
[HttpPost("import-products")]
|
|
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.Token))
|
|
return BadRequest(new { error = "Token is required." });
|
|
|
|
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;
|
|
}
|
|
}
|