diff --git a/docs/moysklad-parity-progress.md b/docs/moysklad-parity-progress.md index 049344a..88f7559 100644 --- a/docs/moysklad-parity-progress.md +++ b/docs/moysklad-parity-progress.md @@ -25,7 +25,7 @@ - Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена). - Недостающее ловит фоновая «Проверка данных», а не запрет сохранения. 5. [~] **«Создать на основании» + связи** — частично: инвентаризация→опр/спис (есть), продажа→возврат - (create-return, ✅), приёмка→оплата (есть). **Осталось:** приёмка→возврат поставщику с преднабором; + (create-return, ✅), приёмка→оплата (есть). **Осталось:** приёмка→возврат поставщику с преднабором (from-supply ✅, 0ad); единый блок «Связанные документы»/«На основании …» во всех документах. 6. [x] **Простой режим**: Оприходование/Списание уже отдельные пункты меню для Admin/Кладовщика вне зависимости от режима (AppLayout.tsx:111-112 — НЕ за advanced-гейтом). Проверено, ничего не прячем. diff --git a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs index 1bed065..57b5d70 100644 --- a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs +++ b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs @@ -125,6 +125,44 @@ public async Task> Get(Guid id, CancellationToke return dto is null ? NotFound() : Ok(dto); } + /// «Создать на основании» приёмки: черновик возврата поставщику с + /// преднабором строк из проведённой приёмки (товар/кол-во/цена), поставщик/ + /// склад/валюта и ссылка на приёмку. Кассир/закупщик лишь правит количества. + [HttpPost("from-supply/{supplyId:guid}"), RequiresPermission("SuppliesEdit")] + public async Task> 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")] public async Task> Create([FromBody] SupplierReturnInput input, CancellationToken ct) { diff --git a/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs b/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs new file mode 100644 index 0000000..e6f75f2 --- /dev/null +++ b/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs @@ -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; + +/// Паритет п.5 (слайс): «создать на основании» — возврат поставщику из +/// проведённой приёмки с преднабором строк и ссылкой на приёмку. +[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); + } +}