feat(supplier-returns): возврат поставщику (P1-7)

Domain SupplierReturn+SupplierReturnLine (по аналогии с Supply). Опциональная
ссылка ReferenceSupplyId на исходную приёмку — при наличии валидируется
совпадение поставщика и status=Posted источника.

EF, миграция Phase6f_SupplierReturns. Контроллер
api/purchases/supplier-returns: CRUD + Post/Unpost. Post создаёт
StockMovement тип SupplierReturn с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар обратно.

Web: /purchases/supplier-returns (list+edit). Пункт «Возвраты поставщикам»
в сайдбаре Закупки. Permissions переиспользуют SuppliesEdit/SuppliesPost/Delete.

Тесты: 4 интеграционных (post→stock, over-return→409, mismatch supplier
по reference→400, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:58:29 +05:00
parent cc9289ef75
commit 6886e1a92b
11 changed files with 1203 additions and 1 deletions

View file

@ -0,0 +1,412 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Возврат поставщику. Списывает товар со склада (как Supply, но
/// со знаком минус и movement type SupplierReturn). Опционально ссылается на
/// исходную приёмку через <c>referenceSupplyId</c>.</summary>
[ApiController]
[Authorize]
[Route("api/purchases/supplier-returns")]
public class SupplierReturnsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public SupplierReturnsController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record SupplierReturnListRow(
Guid Id, string Number, DateTime Date, SupplierReturnStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt,
Guid? ReferenceSupplyId, string? ReferenceSupplyNumber);
public record SupplierReturnLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder,
decimal? StockAtStore);
public record SupplierReturnDto(
Guid Id, string Number, DateTime Date, SupplierReturnStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
Guid? ReferenceSupplyId, string? ReferenceSupplyNumber,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplierReturnLineDto> Lines);
public record SupplierReturnLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice);
public record SupplierReturnInput(
DateTime Date,
Guid SupplierId, Guid StoreId, Guid CurrencyId,
Guid? ReferenceSupplyId,
string? Notes,
IReadOnlyList<SupplierReturnLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<SupplierReturnListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] SupplierReturnStatus? status,
[FromQuery] Guid? supplierId,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from r in _db.SupplierReturns.AsNoTracking()
join cp in _db.Counterparties on r.SupplierId equals cp.Id
join st in _db.Stores on r.StoreId equals st.Id
join cu in _db.Currencies on r.CurrencyId equals cu.Id
select new { r, cp, st, cu };
if (status is not null) q = q.Where(x => x.r.Status == status);
if (supplierId is not null) q = q.Where(x => x.r.SupplierId == supplierId);
if (storeId is not null) q = q.Where(x => x.r.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.r.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.r.Number),
("number", true) => q.OrderByDescending(x => x.r.Number),
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.r.Date),
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.r.Date),
("status", false) => q.OrderBy(x => x.r.Status).ThenByDescending(x => x.r.Date),
("status", true) => q.OrderByDescending(x => x.r.Status).ThenByDescending(x => x.r.Date),
("total", false) => q.OrderBy(x => x.r.Total).ThenByDescending(x => x.r.Date),
("total", true) => q.OrderByDescending(x => x.r.Total).ThenByDescending(x => x.r.Date),
("date", false) => q.OrderBy(x => x.r.Date).ThenBy(x => x.r.Number),
_ => q.OrderByDescending(x => x.r.Date).ThenByDescending(x => x.r.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplierReturnListRow(
x.r.Id, x.r.Number, x.r.Date, x.r.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.r.Total,
x.r.Lines.Count,
x.r.PostedAt,
x.r.ReferenceSupplyId,
x.r.ReferenceSupplyId == null ? null : _db.Supplies.Where(s => s.Id == x.r.ReferenceSupplyId).Select(s => s.Number).FirstOrDefault()))
.ToListAsync(ct);
return new PagedResult<SupplierReturnListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplierReturnDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<SupplierReturnDto>> Create([FromBody] SupplierReturnInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Возврат должен содержать хотя бы одну позицию." });
if (input.ReferenceSupplyId is { } refId)
{
var refSupply = await _db.Supplies.AsNoTracking().FirstOrDefaultAsync(s => s.Id == refId, ct);
if (refSupply is null)
return BadRequest(new { error = "Исходная приёмка не найдена.", field = "referenceSupplyId" });
if (refSupply.Status != SupplyStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённой приёмки.", field = "referenceSupplyId" });
if (refSupply.SupplierId != input.SupplierId)
return BadRequest(new { error = "Поставщик возврата должен совпадать с поставщиком исходной приёмки.", field = "supplierId" });
}
var number = await GenerateNumberAsync(input.Date, ct);
var r = new SupplierReturn
{
Number = number,
Date = input.Date,
Status = SupplierReturnStatus.Draft,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
ReferenceSupplyId = input.ReferenceSupplyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
r.Lines.Add(new SupplierReturnLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
r.Total = r.Lines.Sum(x => x.LineTotal);
_db.SupplierReturns.Add(r);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(r.Id, ct);
return CreatedAtAction(nameof(Get), new { id = r.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("Supplier") ? "supplierId"
: name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: name.Contains("Supply") ? "referenceSupplyId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplierReturnInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Возврат должен содержать хотя бы одну позицию." });
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
r.Date = input.Date;
r.SupplierId = input.SupplierId;
r.StoreId = input.StoreId;
r.CurrencyId = input.CurrencyId;
r.ReferenceSupplyId = input.ReferenceSupplyId;
r.Notes = input.Notes;
_db.SupplierReturnLines.RemoveRange(r.Lines);
r.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
r.Lines.Add(new SupplierReturnLine
{
SupplierReturnId = r.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
r.Total = r.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.SupplierReturns.Remove(r);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status == SupplierReturnStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (r.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода в минус.
var byProduct = r.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 == r.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var x in byProduct)
{
stocks.TryGetValue(x.ProductId, out var available);
if (available < x.Quantity)
{
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = x.ProductId,
productName = name,
returnQty = x.Quantity,
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 r.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: r.StoreId,
Quantity: -line.Quantity,
Type: MovementType.SupplierReturn,
DocumentType: "supplier-return",
DocumentId: r.Id,
DocumentNumber: r.Number,
UnitCost: line.UnitPrice,
OccurredAt: r.Date), ct);
}
r.Status = SupplierReturnStatus.Posted;
r.PostedAt = DateTime.UtcNow;
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("SuppliesPost")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var r = await _db.SupplierReturns.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct);
if (r is null) return NotFound();
if (r.Status != SupplierReturnStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse — добавляем товар обратно, проверка на минус не нужна (только растёт).
foreach (var line in r.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: r.StoreId,
Quantity: line.Quantity,
Type: MovementType.SupplierReturn,
DocumentType: "supplier-return-reversal",
DocumentId: r.Id,
DocumentNumber: r.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена возврата {r.Number}"), ct);
}
r.Status = SupplierReturnStatus.Draft;
r.PostedAt = null;
await _db.SaveChangesAsync(ct);
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.SupplierReturns
.Where(r => r.Number.StartsWith(prefix))
.OrderByDescending(r => r.Number)
.Select(r => r.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<SupplierReturnDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from r in _db.SupplierReturns.AsNoTracking()
join cp in _db.Counterparties on r.SupplierId equals cp.Id
join st in _db.Stores on r.StoreId equals st.Id
join cu in _db.Currencies on r.CurrencyId equals cu.Id
where r.Id == id
select new { r, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
string? refNumber = row.r.ReferenceSupplyId is null ? null
: await _db.Supplies.Where(s => s.Id == row.r.ReferenceSupplyId).Select(s => s.Number).FirstOrDefaultAsync(ct);
var lines = await (from l in _db.SupplierReturnLines.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.SupplierReturnId == id
orderby l.SortOrder
select new SupplierReturnLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.r.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new SupplierReturnDto(
row.r.Id, row.r.Number, row.r.Date, row.r.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.r.ReferenceSupplyId, refNumber,
row.r.Notes,
row.r.Total, row.r.PostedAt,
lines);
}
}

View file

@ -0,0 +1,58 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum SupplierReturnStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Возврат поставщику. Отгрузка товара обратно поставщику (брак, излишек,
/// неликвид). При проведении создаёт <see cref="Inventory.StockMovement"/> с типом
/// <see cref="Inventory.MovementType.SupplierReturn"/> и отрицательным Quantity.
/// Опционально ссылается на исходную приёмку через <see cref="ReferenceSupplyId"/>.</summary>
public class SupplierReturn : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public SupplierReturnStatus Status { get; set; } = SupplierReturnStatus.Draft;
public Guid SupplierId { get; set; }
public Counterparty Supplier { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>Опциональная ссылка на исходную приёмку.</summary>
public Guid? ReferenceSupplyId { get; set; }
public Supply? ReferenceSupply { get; set; }
public string? Notes { get; set; }
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
}
public class SupplierReturnLine : TenantEntity
{
public Guid SupplierReturnId { get; set; }
public SupplierReturn SupplierReturn { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -47,6 +47,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Enter> Enters => Set<Enter>();
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
public DbSet<SupplierReturn> SupplierReturns => Set<SupplierReturn>();
public DbSet<SupplierReturnLine> SupplierReturnLines => Set<SupplierReturnLine>();
public DbSet<Loss> Losses => Set<Loss>();
public DbSet<LossLine> LossLines => Set<LossLine>();

View file

@ -66,5 +66,38 @@ public static void ConfigurePurchases(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
b.Entity<SupplierReturn>(e =>
{
e.ToTable("supplier_returns");
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.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ReferenceSupply).WithMany().HasForeignKey(x => x.ReferenceSupplyId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.SupplierReturn).HasForeignKey(l => l.SupplierReturnId).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 });
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
e.HasIndex(x => x.ReferenceSupplyId);
});
b.Entity<SupplierReturnLine>(e =>
{
e.ToTable("supplier_return_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).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,82 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6f — возврат поставщику (SupplierReturn).
///
/// Документ возврата товара поставщику. При проведении создаёт
/// stock_movements тип SupplierReturn с отрицательным Quantity.
/// Опционально ссылается на исходную приёмку.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528050000_Phase6f_SupplierReturns")]
public partial class Phase6f_SupplierReturns : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.supplier_returns (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""Status"" integer NOT NULL,
""SupplierId"" uuid NOT NULL,
""StoreId"" uuid NOT NULL,
""CurrencyId"" uuid NOT NULL,
""ReferenceSupplyId"" uuid,
""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_supplier_returns_counterparties_SupplierId"" FOREIGN KEY (""SupplierId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_supplier_returns_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_supplier_returns_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_supplier_returns_supplies_ReferenceSupplyId"" FOREIGN KEY (""ReferenceSupplyId"") REFERENCES public.supplies(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_supplier_returns_OrganizationId_Number"" ON public.supplier_returns (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_OrganizationId_Date"" ON public.supplier_returns (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_OrganizationId_Status"" ON public.supplier_returns (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_OrganizationId_SupplierId"" ON public.supplier_returns (""OrganizationId"", ""SupplierId"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_StoreId"" ON public.supplier_returns (""StoreId"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_CurrencyId"" ON public.supplier_returns (""CurrencyId"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_returns_ReferenceSupplyId"" ON public.supplier_returns (""ReferenceSupplyId"");
CREATE TABLE IF NOT EXISTS public.supplier_return_lines (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""SupplierReturnId"" uuid NOT NULL,
""ProductId"" uuid NOT NULL,
""Quantity"" numeric(18,4) NOT NULL,
""UnitPrice"" 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_supplier_return_lines_supplier_returns_SupplierReturnId""
FOREIGN KEY (""SupplierReturnId"") REFERENCES public.supplier_returns(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_supplier_return_lines_products_ProductId""
FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_supplier_return_lines_SupplierReturnId"" ON public.supplier_return_lines (""SupplierReturnId"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_return_lines_ProductId"" ON public.supplier_return_lines (""ProductId"");
CREATE INDEX IF NOT EXISTS ""IX_supplier_return_lines_OrganizationId_ProductId"" ON public.supplier_return_lines (""OrganizationId"", ""ProductId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.supplier_return_lines;
DROP TABLE IF EXISTS public.supplier_returns;
");
}
}
}

View file

@ -36,6 +36,8 @@ import { TransfersPage } from '@/pages/TransfersPage'
import { TransferEditPage } from '@/pages/TransferEditPage'
import { InventoriesPage } from '@/pages/InventoriesPage'
import { InventoryEditPage } from '@/pages/InventoryEditPage'
import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout'
@ -120,6 +122,9 @@ export default function App() {
<Route path="/inventory/inventories" element={<InventoriesPage />} />
<Route path="/inventory/inventories/new" element={<InventoryEditPage />} />
<Route path="/inventory/inventories/:id" element={<InventoryEditPage />} />
<Route path="/purchases/supplier-returns" element={<SupplierReturnsPage />} />
<Route path="/purchases/supplier-returns/new" element={<SupplierReturnEditPage />} />
<Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} />
<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, ArrowRightLeft, ClipboardCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2,
} from 'lucide-react'
import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -97,6 +97,7 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'Возвраты поставщикам' },
]})
}

View file

@ -224,6 +224,36 @@ export interface InventoryDto {
lines: InventoryLineDto[];
}
export const SupplierReturnStatus = { Draft: 0, Posted: 1 } as const
export type SupplierReturnStatus = (typeof SupplierReturnStatus)[keyof typeof SupplierReturnStatus]
export interface SupplierReturnListRow {
id: string; number: string; date: string; status: SupplierReturnStatus;
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null;
referenceSupplyId: string | null; referenceSupplyNumber: string | null;
}
export interface SupplierReturnLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
stockAtStore: number | null;
}
export interface SupplierReturnDto {
id: string; number: string; date: string; status: SupplierReturnStatus;
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
referenceSupplyId: string | null; referenceSupplyNumber: string | null;
notes: string | null;
total: number; postedAt: string | null;
lines: SupplierReturnLineDto[];
}
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]

View file

@ -0,0 +1,366 @@
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, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplierReturnStatus, type SupplierReturnDto, type Product } from '@/lib/types'
interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
stockAtStore: number | null
}
interface Form {
date: string
supplierId: string
storeId: string
currencyId: string
referenceSupplyId: string | null
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(), supplierId: '', storeId: '', currencyId: '',
referenceSupplyId: null, notes: '', lines: [],
}
export function SupplierReturnEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
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/purchases/supplier-returns', id],
queryFn: async () => (await api.get<SupplierReturnDto>(`/api/purchases/supplier-returns/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
supplierId: s.supplierId,
storeId: s.storeId,
currencyId: s.currencyId,
referenceSupplyId: s.referenceSupplyId,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitPrice: l.unitPrice,
stockAtStore: l.stockAtStore,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
if (isNew) {
if (!form.storeId && stores.data?.length) {
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
? currencies.data.find((c) => c.id === org.data!.defaultCurrencyId)
: currencies.data.find((c) => c.code === 'KZT')
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
}
}
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === SupplierReturnStatus.Draft
const isPosted = existing.data?.status === SupplierReturnStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
supplierId: form.supplierId,
storeId: form.storeId,
currencyId: form.currencyId,
referenceSupplyId: form.referenceSupplyId,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice,
})),
}
if (isNew) return (await api.post<SupplierReturnDto>('/api/purchases/supplier-returns', payload)).data
await api.put(`/api/purchases/supplier-returns/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplier-returns'] })
navigate(created ? `/purchases/supplier-returns/${created.id}` : `/purchases/supplier-returns/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplier-returns/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplier-returns'] })
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/purchases/supplier-returns/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplier-returns'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplier-returns/${id}`) },
onSuccess: () => navigate('/purchases/supplier-returns'),
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,
unitPrice: p.cost ?? p.referencePrice ?? 0,
stockAtStore: 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.supplierId && !!form.storeId && !!form.currencyId
&& 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="/purchases/supplier-returns" 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="Поставщик *">
<AsyncSelect
url="/api/catalog/counterparties"
value={form.supplierId}
disabled={isPosted}
onChange={(v) => setForm({ ...form, supplierId: v })}
placeholder="Выберите поставщика…"
/>
</Field>
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</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 })}
placeholder="Причина возврата (брак, излишек, неликвид)…" />
</Field>
</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-[100px] 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.stockAtStore != null ? l.stockAtStore.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.unitPrice} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => updateLine(i, { unitPrice: 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)} {existing.data?.currencyCode ?? ''}
</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,65 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus } 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 SupplierReturnListRow, SupplierReturnStatus } from '@/lib/types'
const URL = '/api/purchases/supplier-returns'
export function SupplierReturnsPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplierReturnListRow>(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="/purchases/supplier-returns/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(`/purchases/supplier-returns/${r.id}`)}
columns={[
{ header: '№', width: '160px', 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 === SupplierReturnStatus.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: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'По приёмке', width: '140px', cell: (r) => r.referenceSupplyNumber ? <span className="font-mono text-xs text-slate-500">{r.referenceSupplyNumber}</span> : '—' },
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ 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)} ${r.currencyCode}` },
]}
empty="Возвратов поставщикам нет."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,147 @@
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 SupplierReturnTests
{
private readonly ApiFactory _factory;
public SupplierReturnTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Return_to_supplier_decrements_stock_unpost_restores()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"sret-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Принять 10 шт.
var supply = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "init", lines = new[] { new { productId, quantity = 10m, unitPrice = 70m } },
});
supply.EnsureSuccessStatusCode();
var supplyId = (await supply.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Вернуть 3 шт поставщику (с reference на приёмку).
var ret = await api.Http.PostAsJsonAsync("/api/purchases/supplier-returns", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
referenceSupplyId = supplyId,
notes = "брак партии",
lines = new[] { new { productId, quantity = 3m, unitPrice = 70m } },
});
ret.EnsureSuccessStatusCode();
var retId = (await ret.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/purchases/supplier-returns/{retId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m);
using var unpost = await api.Http.PostAsJsonAsync($"/api/purchases/supplier-returns/{retId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
}
[Fact]
public async Task Cannot_return_more_than_in_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"sret-over-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Принять 2 шт.
var supply = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "init", lines = new[] { new { productId, quantity = 2m, unitPrice = 50m } },
});
supply.EnsureSuccessStatusCode();
var supplyId = (await supply.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode();
// Попытаться вернуть 5.
var ret = await api.Http.PostAsJsonAsync("/api/purchases/supplier-returns", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
referenceSupplyId = (string?)null,
notes = "over", lines = new[] { new { productId, quantity = 5m, unitPrice = 50m } },
});
ret.EnsureSuccessStatusCode();
var retId = (await ret.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/purchases/supplier-returns/{retId}/post", new { });
((int)post.StatusCode).Should().Be(409);
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(2m);
}
[Fact]
public async Task Reference_supplier_mismatch_400()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"sret-mm-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierA = await api.CreateCounterpartyAsync($"SupA-{Guid.NewGuid():N}");
var supplierB = await api.CreateCounterpartyAsync($"SupB-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var supply = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId = supplierA, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "from A", lines = new[] { new { productId, quantity = 3m, unitPrice = 50m } },
});
supply.EnsureSuccessStatusCode();
var supplyId = (await supply.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { })).EnsureSuccessStatusCode();
// Попытка возвратить supplier B, ссылаясь на приёмку от A — должен быть 400.
using var resp = await api.Http.PostAsJsonAsync("/api/purchases/supplier-returns", new
{
date = DateTime.UtcNow, supplierId = supplierB, storeId = refs.StoreId, currencyId = refs.CurrencyId,
referenceSupplyId = supplyId, notes = "mismatch",
lines = new[] { new { productId, quantity = 1m, unitPrice = 50m } },
});
((int)resp.StatusCode).Should().Be(400);
}
[Fact]
public async Task Tenant_isolation_supplier_return()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"sret-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"sret-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var supplierA = await a.CreateCounterpartyAsync($"Sup-{Guid.NewGuid():N}");
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var resp = await a.Http.PostAsJsonAsync("/api/purchases/supplier-returns", new
{
date = DateTime.UtcNow, supplierId = supplierA, storeId = refsA.StoreId, currencyId = refsA.CurrencyId,
referenceSupplyId = (string?)null, notes = "iso",
lines = new[] { new { productId = productA, quantity = 1m, unitPrice = 10m } },
});
resp.EnsureSuccessStatusCode();
var retId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/purchases/supplier-returns?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == retId);
using var direct = await b.Http.GetAsync($"/api/purchases/supplier-returns/{retId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}