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:
parent
cc9289ef75
commit
6886e1a92b
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
58
src/food-market.domain/Purchases/SupplierReturn.cs
Normal file
58
src/food-market.domain/Purchases/SupplierReturn.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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: 'Возвраты поставщикам' },
|
||||
]})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
366
src/food-market.web/src/pages/SupplierReturnEditPage.tsx
Normal file
366
src/food-market.web/src/pages/SupplierReturnEditPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
src/food-market.web/src/pages/SupplierReturnsPage.tsx
Normal file
65
src/food-market.web/src/pages/SupplierReturnsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
tests/food-market.IntegrationTests/SupplierReturnTests.cs
Normal file
147
tests/food-market.IntegrationTests/SupplierReturnTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue