feat(sales): возврат — способ по умолчанию = оплата чека; иной способ по праву+причине
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

П.3 паритета: create-return уже ставит способ исходного чека. В ручном возврате
(IsReturn + referenceSaleId) если способ отличается от оплаты чека — нужно право
администратора и причина (RefundReason, пишется в Notes). Сообщения понятные.

Тест: тот же способ ок; иной без причины → 400; иной с причиной (админ) → 201.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 05:50:41 +05:00
parent 0ca0d6cc3b
commit c777b5b692
2 changed files with 105 additions and 2 deletions

View file

@ -96,7 +96,21 @@ public record RetailSaleInput(
/// <summary>Sprint 9: код промокода. Если задан — сервер найдёт active- /// <summary>Sprint 9: код промокода. Если задан — сервер найдёт active-
/// promotion с этим кодом, проверит период/MinSaleAmount/Scope и /// promotion с этим кодом, проверит период/MinSaleAmount/Scope и
/// применит скидку (PromotionDiscount/PromotionId/PromotionCode).</summary> /// применит скидку (PromotionDiscount/PromotionId/PromotionCode).</summary>
string? PromotionCode = null); string? PromotionCode = null,
/// <summary>Причина возврата ИНЫМ способом оплаты (не как в исходном чеке).
/// Обязательна, когда способ возврата отличается от способа оплаты чека-
/// основания; само изменение способа — только с правом администратора.</summary>
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); public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@ -315,6 +329,16 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" }); return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" });
if (refSale.Status != RetailSaleStatus.Posted) if (refSale.Status != RetailSaleStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" }); 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 var sale = new RetailSale
@ -329,7 +353,9 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
Payment = input.Payment, Payment = input.Payment,
PaidCash = R(input.PaidCash), PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard), 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, IsReturn = input.IsReturn,
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null, ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
}; };

View file

@ -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;
/// <summary>Паритет возврата покупателя (п.3): способ возврата по умолчанию =
/// способ оплаты исходного чека; иной способ — только по праву администратора и
/// с причиной.</summary>
[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<JsonElement>()).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<JsonElement>()).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());
}
}