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
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:
parent
0ca0d6cc3b
commit
c777b5b692
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue