From c777b5b692e0f17839167e356262e73155b6d3ed Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 05:50:41 +0500 Subject: [PATCH] =?UTF-8?q?feat(sales):=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=82=20=E2=80=94=20=D1=81=D0=BF=D0=BE=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20=3D=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D0=B0=20?= =?UTF-8?q?=D1=87=D0=B5=D0=BA=D0=B0;=20=D0=B8=D0=BD=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BE=D0=B1=20=D0=BF=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D1=83+=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit П.3 паритета: create-return уже ставит способ исходного чека. В ручном возврате (IsReturn + referenceSaleId) если способ отличается от оплаты чека — нужно право администратора и причина (RefundReason, пишется в Notes). Сообщения понятные. Тест: тот же способ ок; иной без причины → 400; иной с причиной (админ) → 201. Co-Authored-By: Claude Opus 4.8 --- .../Sales/RetailSalesController.cs | 30 +++++++- .../RetailReturnParityTests.cs | 77 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/food-market.IntegrationTests/RetailReturnParityTests.cs diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 35c79f8..6170444 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -96,7 +96,21 @@ public record RetailSaleInput( /// Sprint 9: код промокода. Если задан — сервер найдёт active- /// promotion с этим кодом, проверит период/MinSaleAmount/Scope и /// применит скидку (PromotionDiscount/PromotionId/PromotionCode). - string? PromotionCode = null); + string? PromotionCode = null, + /// Причина возврата ИНЫМ способом оплаты (не как в исходном чеке). + /// Обязательна, когда способ возврата отличается от способа оплаты чека- + /// основания; само изменение способа — только с правом администратора. + string? RefundReason = null); + + private static string PaymentLabel(PaymentMethod m) => m switch + { + PaymentMethod.Cash => "наличные", + PaymentMethod.Card => "карта", + PaymentMethod.BankTransfer => "перевод", + PaymentMethod.Bonus => "бонусы", + PaymentMethod.Mixed => "смешанная", + _ => m.ToString(), + }; public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions); @@ -315,6 +329,16 @@ public async Task> Create([FromBody] RetailSaleInput return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" }); if (refSale.Status != RetailSaleStatus.Posted) return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" }); + // Способ возврата по умолчанию = способ оплаты исходного чека (как МойСклад). + // Иной способ — только с правом администратора и с указанием причины. + if (input.Payment != refSale.Payment) + { + var isAdmin = User.IsInRole("Admin") || User.IsInRole("SuperAdmin"); + if (!isAdmin) + return StatusCode(403, new { error = $"Возврат способом «{PaymentLabel(input.Payment)}» отличается от оплаты чека («{PaymentLabel(refSale.Payment)}»). Нужно право администратора.", code = "refund-method-admin" }); + if (string.IsNullOrWhiteSpace(input.RefundReason)) + return BadRequest(new { error = "Укажите причину возврата иным способом оплаты.", field = "refundReason", code = "refund-reason-required" }); + } } var sale = new RetailSale @@ -329,7 +353,9 @@ public async Task> Create([FromBody] RetailSaleInput Payment = input.Payment, PaidCash = R(input.PaidCash), PaidCard = R(input.PaidCard), - Notes = input.Notes, + Notes = string.IsNullOrWhiteSpace(input.RefundReason) + ? input.Notes + : $"{input.Notes}{(string.IsNullOrWhiteSpace(input.Notes) ? "" : " · ")}Возврат иным способом: {input.RefundReason.Trim()}".Trim(), IsReturn = input.IsReturn, ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null, }; diff --git a/tests/food-market.IntegrationTests/RetailReturnParityTests.cs b/tests/food-market.IntegrationTests/RetailReturnParityTests.cs new file mode 100644 index 0000000..29286f4 --- /dev/null +++ b/tests/food-market.IntegrationTests/RetailReturnParityTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Паритет возврата покупателя (п.3): способ возврата по умолчанию = +/// способ оплаты исходного чека; иной способ — только по праву администратора и +/// с причиной. +[Collection(ApiCollection.Name)] +public class RetailReturnParityTests +{ + private readonly ApiFactory _factory; + public RetailReturnParityTests(ApiFactory factory) => _factory = factory; + + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + private async Task<(string saleId, string productId, ApiActor.Refs refs)> PostedCashSaleAsync(ApiActor api) + { + var refs = await api.LoadRefsAsync(); + var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + // немного остатка + var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = p, quantity = 10m, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + payment = 0 /*Cash*/, paidCash = 100m, paidCard = 0m, isReturn = false, + lines = new[] { new { productId = p, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m, sortOrder = 0 } }, + }); + sale.StatusCode.Should().Be(HttpStatusCode.Created); + var saleId = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode(); + return (saleId, p, refs); + } + + private object ReturnBody(string saleId, string productId, ApiActor.Refs refs, int payment, string? reason) + => new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + payment, paidCash = payment == 0 ? 100m : 0m, paidCard = payment == 1 ? 100m : 0m, + isReturn = true, referenceSaleId = saleId, refundReason = reason, + lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m, sortOrder = 0 } }, + }; + + [Fact] + public async Task Return_same_method_ok_different_method_needs_reason() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"ret-{Guid.NewGuid():N}"); // signup-пользователь = Admin + var (saleId, p, refs) = await PostedCashSaleAsync(api); + + // Тот же способ (наличные) — ок. + var same = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 0, null)); + same.StatusCode.Should().Be(HttpStatusCode.Created, await same.Content.ReadAsStringAsync()); + + // Иной способ (карта) без причины — 400 (пользователь-админ проходит проверку права). + var noReason = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 1, null)); + noReason.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await noReason.Content.ReadAsStringAsync()).Should().Contain("причину"); + + // Иной способ с причиной — ок (админ + причина). + var withReason = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 1, "покупатель просит на карту")); + withReason.StatusCode.Should().Be(HttpStatusCode.Created, await withReason.Content.ReadAsStringAsync()); + } +}