feat(purchases): создать возврат поставщику на основании приёмки (п.5 слайс)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

POST /api/purchases/supplier-returns/from-supply/{supplyId}: черновик возврата с
преднабором строк из проведённой приёмки (товар/кол-во/цена), поставщик/склад/валюта
и ссылка ReferenceSupplyId. Integration-тест: преднабор + связь.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 06:16:33 +05:00
parent ddf7918eb1
commit ad2e3b3117
3 changed files with 81 additions and 1 deletions

View file

@ -25,7 +25,7 @@
- Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена). - Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена).
- Недостающее ловит фоновая «Проверка данных», а не запрет сохранения. - Недостающее ловит фоновая «Проверка данных», а не запрет сохранения.
5. [~] **«Создать на основании» + связи** — частично: инвентаризация→опр/спис (есть), продажа→возврат 5. [~] **«Создать на основании» + связи** — частично: инвентаризация→опр/спис (есть), продажа→возврат
(create-return, ✅), приёмка→оплата (есть). **Осталось:** приёмка→возврат поставщику с преднабором; (create-return, ✅), приёмка→оплата (есть). **Осталось:** приёмка→возврат поставщику с преднабором (from-supply ✅, 0ad);
единый блок «Связанные документы»/«На основании …» во всех документах. единый блок «Связанные документы»/«На основании …» во всех документах.
6. [x] **Простой режим**: Оприходование/Списание уже отдельные пункты меню для Admin/Кладовщика вне 6. [x] **Простой режим**: Оприходование/Списание уже отдельные пункты меню для Admin/Кладовщика вне
зависимости от режима (AppLayout.tsx:111-112 — НЕ за advanced-гейтом). Проверено, ничего не прячем. зависимости от режима (AppLayout.tsx:111-112 — НЕ за advanced-гейтом). Проверено, ничего не прячем.

View file

@ -125,6 +125,44 @@ public async Task<ActionResult<SupplierReturnDto>> Get(Guid id, CancellationToke
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
/// <summary>«Создать на основании» приёмки: черновик возврата поставщику с
/// преднабором строк из проведённой приёмки (товар/кол-во/цена), поставщик/
/// склад/валюта и ссылка на приёмку. Кассир/закупщик лишь правит количества.</summary>
[HttpPost("from-supply/{supplyId:guid}"), RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<SupplierReturnDto>> CreateFromSupply(Guid supplyId, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == supplyId, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Posted)
return BadRequest(new { error = "Создать возврат можно только из проведённой приёмки." });
if (supply.Lines.Count == 0)
return BadRequest(new { error = "В приёмке нет позиций." });
var number = await GenerateNumberAsync(DateTime.UtcNow, ct);
var r = new SupplierReturn
{
Number = number,
Date = DateTime.UtcNow,
Status = SupplierReturnStatus.Draft,
SupplierId = supply.SupplierId,
StoreId = supply.StoreId,
CurrencyId = supply.CurrencyId,
ReferenceSupplyId = supply.Id,
Notes = $"Возврат по приёмке {supply.Number}",
};
var order = 0;
foreach (var l in supply.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;
return CreatedAtAction(nameof(Get), new { id = r.Id }, await GetInternal(r.Id, ct));
}
[HttpPost, RequiresPermission("SuppliesEdit")] [HttpPost, RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<SupplierReturnDto>> Create([FromBody] SupplierReturnInput input, CancellationToken ct) public async Task<ActionResult<SupplierReturnDto>> Create([FromBody] SupplierReturnInput input, CancellationToken ct)
{ {

View file

@ -0,0 +1,42 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Паритет п.5 (слайс): «создать на основании» — возврат поставщику из
/// проведённой приёмки с преднабором строк и ссылкой на приёмку.</summary>
[Collection(ApiCollection.Name)]
public class SupplierReturnFromSupplyTests
{
private readonly ApiFactory _factory;
public SupplierReturnFromSupplyTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task CreateFromSupply_presets_lines_and_links_supply()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"spret-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m);
var resp = await api.Http.PostAsJsonAsync($"/api/purchases/supplier-returns/from-supply/{supplyId}", new { });
var body = await resp.Content.ReadAsStringAsync();
resp.StatusCode.Should().Be(HttpStatusCode.Created, body);
var r = JsonDocument.Parse(body).RootElement;
r.GetProperty("referenceSupplyId").GetString().Should().Be(supplyId);
var lines = r.GetProperty("lines").EnumerateArray().ToList();
lines.Should().ContainSingle(l => l.GetProperty("productId").GetString() == product);
lines.First().GetProperty("quantity").GetDecimal().Should().Be(10m);
lines.First().GetProperty("unitPrice").GetDecimal().Should().Be(100m);
}
}