feat(transfers): атомарное перемещение между складами (P1-3)

Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).

Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.

Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:32:32 +05:00
parent ab74d06706
commit fa1e123327
12 changed files with 1229 additions and 2 deletions

View file

@ -0,0 +1,459 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Перемещение (Transfer): атомарное движение товара между складами.
/// Post создаёт ПАРУ движений в <see cref="StockMovement"/>: TransferOut с
/// отрицательным Quantity из FromStore + TransferIn с положительным в
/// ToStore. Серьёзный кейс: post→unpost не должен оставить orphan-движений
/// (только один из двух) — оба создаются и оба «отменяются» в одной
/// транзакции.</summary>
[ApiController]
[Authorize]
[Route("api/inventory/transfers")]
public class TransfersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public TransfersController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record TransferListRow(
Guid Id, string Number, DateTime Date, TransferStatus Status,
Guid FromStoreId, string FromStoreName,
Guid ToStoreId, string ToStoreName,
decimal Total, int LineCount,
DateTime? PostedAt);
public record TransferLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
decimal? StockAtFrom);
public record TransferDto(
Guid Id, string Number, DateTime Date, TransferStatus Status,
Guid FromStoreId, string FromStoreName,
Guid ToStoreId, string ToStoreName,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<TransferLineDto> Lines);
public record TransferLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record TransferInput(
DateTime Date, Guid FromStoreId, Guid ToStoreId,
string? Notes,
IReadOnlyList<TransferLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<TransferListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] TransferStatus? status,
[FromQuery] Guid? fromStoreId,
[FromQuery] Guid? toStoreId,
CancellationToken ct)
{
var q = from t in _db.Transfers.AsNoTracking()
join fs in _db.Stores on t.FromStoreId equals fs.Id
join ts in _db.Stores on t.ToStoreId equals ts.Id
select new { t, fs, ts };
if (status is not null) q = q.Where(x => x.t.Status == status);
if (fromStoreId is not null) q = q.Where(x => x.t.FromStoreId == fromStoreId);
if (toStoreId is not null) q = q.Where(x => x.t.ToStoreId == toStoreId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.t.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.t.Number),
("number", true) => q.OrderByDescending(x => x.t.Number),
("status", false) => q.OrderBy(x => x.t.Status).ThenByDescending(x => x.t.Date),
("status", true) => q.OrderByDescending(x => x.t.Status).ThenByDescending(x => x.t.Date),
("total", false) => q.OrderBy(x => x.t.Total).ThenByDescending(x => x.t.Date),
("total", true) => q.OrderByDescending(x => x.t.Total).ThenByDescending(x => x.t.Date),
("date", false) => q.OrderBy(x => x.t.Date).ThenBy(x => x.t.Number),
_ => q.OrderByDescending(x => x.t.Date).ThenByDescending(x => x.t.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new TransferListRow(
x.t.Id, x.t.Number, x.t.Date, x.t.Status,
x.fs.Id, x.fs.Name,
x.ts.Id, x.ts.Name,
x.t.Total,
x.t.Lines.Count,
x.t.PostedAt))
.ToListAsync(ct);
return new PagedResult<TransferListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<TransferDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("TransferEdit")]
public async Task<ActionResult<TransferDto>> Create([FromBody] TransferInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.FromStoreId), input.FromStoreId),
(nameof(input.ToStoreId), input.ToStoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.FromStoreId == input.ToStoreId)
return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var t = new Transfer
{
Number = number,
Date = input.Date,
Status = TransferStatus.Draft,
FromStoreId = input.FromStoreId,
ToStoreId = input.ToStoreId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
t.Lines.Add(new TransferLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
t.Total = t.Lines.Sum(x => x.LineTotal);
_db.Transfers.Add(t);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(t.Id, ct);
return CreatedAtAction(nameof(Get), new { id = t.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("FromStore") ? "fromStoreId"
: name.Contains("ToStore") ? "toStoreId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] TransferInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.FromStoreId), input.FromStoreId),
(nameof(input.ToStoreId), input.ToStoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.FromStoreId == input.ToStoreId)
return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." });
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
t.Date = input.Date;
t.FromStoreId = input.FromStoreId;
t.ToStoreId = input.ToStoreId;
t.Notes = input.Notes;
_db.TransferLines.RemoveRange(t.Lines);
t.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
t.Lines.Add(new TransferLine
{
TransferId = t.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
t.Total = t.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Transfers.Remove(t);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Проверка: на FromStore хватает товара (нельзя уйти в минус).
var byProduct = t.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == t.FromStoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
requested = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "На складе-отправителе недостаточно товара.",
lines = conflicts,
});
}
// Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in t.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.FromStoreId,
Quantity: -line.Quantity,
Type: MovementType.TransferOut,
DocumentType: "transfer-out",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: t.Date), ct);
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.ToStoreId,
Quantity: line.Quantity,
Type: MovementType.TransferIn,
DocumentType: "transfer-in",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: t.Date), ct);
}
t.Status = TransferStatus.Posted;
t.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("TransferEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (t is null) return NotFound();
if (t.Status != TransferStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: на ToStore должен хватать товар (могли уже использовать).
var byProduct = t.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == t.ToStoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
availableAtTo = available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить перемещение: на складе-получателе недостаточно товара (часть уже расходована).",
lines = conflicts,
});
}
// Атомарная транзакция: пара обратных движений.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in t.Lines)
{
// Возвращаем на FromStore (+Quantity).
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.FromStoreId,
Quantity: line.Quantity,
Type: MovementType.TransferOut,
DocumentType: "transfer-out-reversal",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {t.Number}"), ct);
// Снимаем с ToStore (-Quantity).
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: t.ToStoreId,
Quantity: -line.Quantity,
Type: MovementType.TransferIn,
DocumentType: "transfer-in-reversal",
DocumentId: t.Id,
DocumentNumber: t.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {t.Number}"), ct);
}
t.Status = TransferStatus.Draft;
t.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-Т-";
var lastNumber = await _db.Transfers
.Where(t => t.Number.StartsWith(prefix))
.OrderByDescending(t => t.Number)
.Select(t => t.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<TransferDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from t in _db.Transfers.AsNoTracking()
join fs in _db.Stores on t.FromStoreId equals fs.Id
join ts in _db.Stores on t.ToStoreId equals ts.Id
where t.Id == id
select new { t, fs, ts }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.TransferLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.TransferId == id
orderby l.SortOrder
select new TransferLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.t.FromStoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new TransferDto(
row.t.Id, row.t.Number, row.t.Date, row.t.Status,
row.fs.Id, row.fs.Name,
row.ts.Id, row.ts.Name,
row.t.Notes,
row.t.Total, row.t.PostedAt,
lines);
}
}

View file

@ -0,0 +1,55 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum TransferStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Документ перемещения товара между складами одной организации.
/// При проведении создаёт ПАРУ движений: TransferOut из FromStore и
/// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен —
/// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений
/// (только один из двух).</summary>
public class Transfer : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public Guid FromStoreId { get; set; }
public Store FromStore { get; set; } = null!;
public Guid ToStoreId { get; set; }
public Store ToStore { get; set; } = null!;
public string? Notes { get; set; }
/// <summary>Балансовая сумма перемещения = Σ Quantity·UnitCost (только для отчётов;
/// при проведении не влияет на Product.Cost).</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<TransferLine> Lines { get; set; } = new List<TransferLine>();
}
public class TransferLine : TenantEntity
{
public Guid TransferId { get; set; }
public Transfer Transfer { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (снимок Product.Cost). На остатках не отражается.</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -35,6 +35,7 @@ public class RolePermissions
public bool InventoryEdit { get; set; }
public bool LossEdit { get; set; }
public bool EnterEdit { get; set; }
public bool TransferEdit { get; set; }
// Отчёты
public bool ReportsView { get; set; }
@ -59,7 +60,7 @@ public static RolePermissions All() => new()
DemandsView = true, DemandsEdit = true, DemandsPost = true,
RetailSalesOperate = true, RetailSalesRefund = true,
CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true,
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true,
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true, TransferEdit = true,
ReportsView = true, ReportsFinanceView = true, ReportsStockView = true,
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true,

View file

@ -50,6 +50,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Loss> Losses => Set<Loss>();
public DbSet<LossLine> LossLines => Set<LossLine>();
public DbSet<Transfer> Transfers => Set<Transfer>();
public DbSet<TransferLine> TransferLines => Set<TransferLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();

View file

@ -67,5 +67,34 @@ public static void ConfigureInventory(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
b.Entity<Transfer>(e =>
{
e.ToTable("transfers");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Total).HasPrecision(18, 4);
e.HasOne(x => x.FromStore).WithMany().HasForeignKey(x => x.FromStoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ToStore).WithMany().HasForeignKey(x => x.ToStoreId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.Transfer).HasForeignKey(l => l.TransferId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
});
b.Entity<TransferLine>(e =>
{
e.ToTable("transfer_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitCost).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
}
}

View file

@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6c — перемещение (Transfer).
///
/// Документ перемещения товара между складами одной организации. При
/// проведении создаётся ПАРА движений в stock_movements: TransferOut
/// (-Qty из FromStore) + TransferIn (+Qty в ToStore). Атомарно.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528020000_Phase6c_Transfers")]
public partial class Phase6c_Transfers : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.transfers (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""Status"" integer NOT NULL,
""FromStoreId"" uuid NOT NULL,
""ToStoreId"" uuid NOT NULL,
""Notes"" varchar(1000),
""Total"" numeric(18,4) NOT NULL,
""PostedAt"" timestamp with time zone,
""PostedByUserId"" uuid,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_transfers_stores_FromStoreId"" FOREIGN KEY (""FromStoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_transfers_stores_ToStoreId"" FOREIGN KEY (""ToStoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Number"" ON public.transfers (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Date"" ON public.transfers (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Status"" ON public.transfers (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_transfers_FromStoreId"" ON public.transfers (""FromStoreId"");
CREATE INDEX IF NOT EXISTS ""IX_transfers_ToStoreId"" ON public.transfers (""ToStoreId"");
CREATE TABLE IF NOT EXISTS public.transfer_lines (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""TransferId"" uuid NOT NULL,
""ProductId"" uuid NOT NULL,
""Quantity"" numeric(18,4) NOT NULL,
""UnitCost"" numeric(18,4) NOT NULL,
""LineTotal"" numeric(18,4) NOT NULL,
""SortOrder"" integer NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_transfer_lines_transfers_TransferId"" FOREIGN KEY (""TransferId"") REFERENCES public.transfers(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_transfer_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_TransferId"" ON public.transfer_lines (""TransferId"");
CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_ProductId"" ON public.transfer_lines (""ProductId"");
CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_OrganizationId_ProductId"" ON public.transfer_lines (""OrganizationId"", ""ProductId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.transfer_lines;
DROP TABLE IF EXISTS public.transfers;
");
}
}
}

View file

@ -32,6 +32,8 @@ import { EntersPage } from '@/pages/EntersPage'
import { EnterEditPage } from '@/pages/EnterEditPage'
import { LossesPage } from '@/pages/LossesPage'
import { LossEditPage } from '@/pages/LossEditPage'
import { TransfersPage } from '@/pages/TransfersPage'
import { TransferEditPage } from '@/pages/TransferEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout'
@ -110,6 +112,9 @@ export default function App() {
<Route path="/inventory/losses" element={<LossesPage />} />
<Route path="/inventory/losses/new" element={<LossEditPage />} />
<Route path="/inventory/losses/:id" element={<LossEditPage />} />
<Route path="/inventory/transfers" element={<TransfersPage />} />
<Route path="/inventory/transfers/new" element={<TransferEditPage />} />
<Route path="/inventory/transfers/:id" element={<TransferEditPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import {
LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft,
} from 'lucide-react'
import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -87,6 +87,7 @@ function buildNav(roles: string[]): NavSection[] {
stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' })
}
sections.push({ group: 'Остатки', items: stock })
}

View file

@ -173,6 +173,32 @@ export interface LossDto {
lines: LossLineDto[];
}
export const TransferStatus = { Draft: 0, Posted: 1 } as const
export type TransferStatus = (typeof TransferStatus)[keyof typeof TransferStatus]
export interface TransferListRow {
id: string; number: string; date: string; status: TransferStatus;
fromStoreId: string; fromStoreName: string;
toStoreId: string; toStoreName: string;
total: number; lineCount: number; postedAt: string | null;
}
export interface TransferLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitCost: number; lineTotal: number; sortOrder: number;
stockAtFrom: number | null;
}
export interface TransferDto {
id: string; number: string; date: string; status: TransferStatus;
fromStoreId: string; fromStoreName: string;
toStoreId: string; toStoreName: string;
notes: string | null;
total: number; postedAt: string | null;
lines: TransferLineDto[];
}
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]

View file

@ -0,0 +1,344 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, ArrowRight, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { TransferStatus, type TransferDto, type Product } from '@/lib/types'
interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitCost: number
stockAtFrom: number | null
}
interface Form {
date: string
fromStoreId: string
toStoreId: string
notes: string
lines: LineRow[]
}
const todayIso = () => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const emptyForm: Form = {
date: todayIso(), fromStoreId: '', toStoreId: '', notes: '', lines: [],
}
export function TransferEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const org = useOrgSettings()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/inventory/transfers', id],
queryFn: async () => (await api.get<TransferDto>(`/api/inventory/transfers/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
fromStoreId: s.fromStoreId,
toStoreId: s.toStoreId,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitCost: l.unitCost,
stockAtFrom: l.stockAtFrom,
})),
})
}
}, [isNew, existing.data])
const isDraft = isNew || existing.data?.status === TransferStatus.Draft
const isPosted = existing.data?.status === TransferStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitCost
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const sameStore = !!form.fromStoreId && form.fromStoreId === form.toStoreId
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
fromStoreId: form.fromStoreId,
toStoreId: form.toStoreId,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId, quantity: l.quantity, unitCost: l.unitCost,
})),
}
if (isNew) return (await api.post<TransferDto>('/api/inventory/transfers', payload)).data
await api.put(`/api/inventory/transfers/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] })
navigate(created ? `/inventory/transfers/${created.id}` : `/inventory/transfers/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/transfers/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/transfers/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/transfers/${id}`) },
onSuccess: () => navigate('/inventory/transfers'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitName,
quantity: 1,
unitCost: p.cost ?? 0,
stockAtFrom: null,
}],
})
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.date && !!form.fromStoreId && !!form.toStoreId && !sameStore
&& form.lines.length > 0 && isDraft
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/inventory/transfers" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое перемещение' : existing.data?.number ?? 'Перемещение'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не перемещён, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Со склада *">
<Select value={form.fromStoreId} disabled={isPosted}
onChange={(e) => setForm({ ...form, fromStoreId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="На склад *" error={sameStore ? 'Склады должны различаться' : undefined}>
<Select value={form.toStoreId} disabled={isPosted}
onChange={(e) => setForm({ ...form, toStoreId: e.target.value })}>
<option value=""></option>
{stores.data?.filter((s) => s.id !== form.fromStoreId).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</Select>
</Field>
<Field label="Комментарий" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
</div>
<div className="mt-3 inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300 px-3 py-2 bg-slate-50 dark:bg-slate-800/50 rounded">
<span className="font-medium">{stores.data?.find((s) => s.id === form.fromStoreId)?.name ?? '—'}</span>
<ArrowRight className="w-4 h-4 text-slate-400" />
<span className="font-medium">{stores.data?.find((s) => s.id === form.toStoreId)?.name ?? '—'}</span>
</div>
{!isNew && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
if (v) {
if (confirm('Провести? Товар спишется с первого склада и встанет на второй.')) post.mutate()
} else {
if (confirm('Снять проведение? Остатки обоих складов вернутся к предыдущим значениям.')) unpost.mutate()
}
}}
/>
</div>
)}
</section>
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium text-slate-900 dark:text-slate-100">Позиции</h2>
{!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить из справочника
</Button>
)}
</div>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-500 py-6 text-center">Нет позиций.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[80px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">На отправителе</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[120px] text-right">Кол-во</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[140px] text-right">Цена ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[140px] text-right">Сумма</th>
<th className="w-8"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium text-slate-800 dark:text-slate-100">{l.productName || '(без названия)'}</div>
{l.productArticle && <div className="text-xs text-slate-500">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol ?? '—'}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">
{l.stockAtFrom != null ? l.stockAtFrom.toLocaleString('ru') : '—'}
</td>
<td className="py-2 px-3 text-right">
<NumberInput value={l.quantity} disabled={isPosted}
onChange={(v) => updateLine(i, { quantity: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right">
<MoneyInput value={l.unitCost} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => updateLine(i, { unitCost: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right font-mono">
{lineTotal(l).toLocaleString('ru', moneyFmt)}
</td>
<td className="py-2 px-1">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)}
className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
<tr className="font-medium">
<td className="py-3 pr-3" colSpan={5}>Итого</td>
<td className="py-3 px-3 text-right font-mono">
{grandTotal.toLocaleString('ru', moneyFmt)}
</td>
<td />
</tr>
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
<ProductPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
</form>
)
}

View file

@ -0,0 +1,69 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ArrowRight } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type TransferListRow, TransferStatus } from '@/lib/types'
const URL = '/api/inventory/transfers'
export function TransfersPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<TransferListRow>(URL)
const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
return (
<ListPageShell
title="Перемещения"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Перевод товара между складами.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/transfers/new">
<Button><Plus className="w-4 h-4" /> Новое перемещение</Button>
</Link>
</>
}
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}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => navigate(`/inventory/transfers/${r.id}`)}
columns={[
{ header: '№', width: '180px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === TransferStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Откуда → Куда', cell: (r) => (
<span className="inline-flex items-center gap-2 text-slate-700">
<span>{r.fromStoreName}</span>
<ArrowRight className="w-3.5 h-3.5 text-slate-400" />
<span>{r.toStoreName}</span>
</span>
)},
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)}` },
]}
empty="Перемещений пока нет."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,161 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class TransferPostUnpostTests
{
private readonly ApiFactory _factory;
public TransferPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Создать второй склад через api (главный уже есть из bootstrap).</summary>
private async Task<string> CreateSecondStore(ApiActor api, string name)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/stores", new
{
name, code = (string?)null, address = "Алматы 2",
phone = (string?)null, managerName = (string?)null,
isMain = false, isActive = true,
});
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
[Fact]
public async Task Post_creates_paired_movements_unpost_reverts_no_orphans()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var to = await CreateSecondStore(api, $"Склад-2-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Оприходовать 10 шт на первый склад.
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Создать перемещение: 4 шт с первого склада на второй.
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = to,
notes = "test",
lines = new[] { new { productId, quantity = 4m, unitCost = 50m } },
});
xfer.EnsureSuccessStatusCode();
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Post → 6 на источнике, 4 на получателе.
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(6m);
(await api.StockOfAsync(to, productId)).Should().Be(4m);
// Проверяем что движений ровно 2 (TransferOut + TransferIn) — никаких orphan.
var movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
var byThisDoc = movs.Where(m =>
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
m.GetProperty("documentId").GetString() == xferId).ToList();
byThisDoc.Should().HaveCount(2, "перемещение порождает строго пару движений Out+In");
// Unpost → 10 и 0. Атомарность: ИЛИ оба отменены, ИЛИ ни одного.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
(await api.StockOfAsync(to, productId)).Should().Be(0m);
// По документу теперь 4 движения (2 forward + 2 reversal), но stock-инвариант держится.
movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
var byThisDocAfter = movs.Where(m =>
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
m.GetProperty("documentId").GetString() == xferId).ToList();
byThisDocAfter.Should().HaveCount(4);
}
[Fact]
public async Task Cannot_create_transfer_to_same_store()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-same-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
using var resp = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = refs.StoreId,
notes = "same",
lines = new[] { new { productId, quantity = 1m, unitCost = 10m } },
});
((int)resp.StatusCode).Should().Be(400);
}
[Fact]
public async Task Post_blocked_when_from_store_short()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-short-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var to = await CreateSecondStore(api, $"Склад-S-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
// На первом 0 шт; пытаемся перевести 5 — должно отбиться.
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = to,
notes = "short",
lines = new[] { new { productId, quantity = 5m, unitCost = 10m } },
});
xfer.EnsureSuccessStatusCode();
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
((int)post.StatusCode).Should().Be(409);
// Никаких движений не должно появиться.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
(await api.StockOfAsync(to, productId)).Should().Be(0m);
}
[Fact]
public async Task Tenant_isolation_transfer()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"xfer-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"xfer-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var toA = await CreateSecondStore(a, $"Склад-A2-{Guid.NewGuid():N}");
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var resp = await a.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refsA.StoreId, toStoreId = toA,
notes = "iso",
lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
});
resp.EnsureSuccessStatusCode();
var xferId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/transfers?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == xferId);
using var direct = await b.Http.GetAsync($"/api/inventory/transfers/{xferId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}