feat(returns): возврат от покупателя (CustomerReturn) (P1-6)

Расширение RetailSale: IsReturn (bool) + ReferenceSaleId (Guid?, self-FK) +
RetailSaleLine.QtyReturned (агрегация для защиты от over-return).
Миграция Phase6e_RetailSaleReturns (IF NOT EXISTS, можно гонять на стейдже).

Контроллер api/sales/retail:
• Create принимает isReturn/referenceSaleId; для возврата с reference —
  валидация что reference существует, не сам возврат, и проведён.
• POST /{id}/create-return — создаёт Draft-возврат от проведённого чека,
  копируя строки с qty = (Quantity - QtyReturned).
• Post с IsReturn=true идёт через PostReturnAsync: проверяет лимит
  возврата по reference (Quantity - QtyReturned), создаёт StockMovement
  тип CustomerReturn с +Quantity, инкрементит QtyReturned на источнике.
• Unpost для возврата зеркально откатывает.
• Запрещён unpost исходного чека пока есть проведённые возвраты на него.

Web: кнопка «Создать возврат» на странице проведённой продажи. DTO
расширены полями isReturn/referenceSale*/qtyReturned.

Тесты: 3 интеграционных (полный цикл sale→create-return→post,
standalone-return без reference, блокировка unpost при активных возвратах).
Полный прогон: 27 зелёных интеграционных, 23 зелёных unit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:51:04 +05:00
parent 561291f226
commit ea29ab63c7
8 changed files with 504 additions and 6 deletions

View file

@ -1 +1 @@
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096}

View file

@ -32,11 +32,13 @@ public record RetailSaleListRow(
Guid? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode,
decimal Total, PaymentMethod Payment, int LineCount,
DateTime? PostedAt);
DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber);
public record RetailSaleLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder);
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder,
decimal QtyReturned);
public record RetailSaleDto(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
@ -47,6 +49,7 @@ public record RetailSaleDto(
decimal Subtotal, decimal DiscountTotal, decimal Total,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput(
@ -61,7 +64,9 @@ public record RetailSaleInput(
[Range(0, 1e10)] decimal PaidCash,
[Range(0, 1e10)] decimal PaidCard,
string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines);
IReadOnlyList<RetailSaleLineInput> Lines,
bool IsReturn = false,
Guid? ReferenceSaleId = null);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@ -182,7 +187,9 @@ public record SalesStatsResponse(
x.cu.Id, x.cu.Code,
x.s.Total, x.s.Payment,
x.s.Lines.Count,
x.s.PostedAt))
x.s.PostedAt,
x.s.IsReturn, x.s.ReferenceSaleId,
x.s.ReferenceSaleId == null ? null : _db.RetailSales.Where(rs => rs.Id == x.s.ReferenceSaleId).Select(rs => rs.Number).FirstOrDefault()))
.ToListAsync(ct);
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
@ -207,6 +214,17 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
// Если возврат привязан к чеку — валидация что reference существует и проведён.
if (input.IsReturn && input.ReferenceSaleId is { } refId)
{
var refSale = await _db.RetailSales.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == refId && !s.IsReturn, ct);
if (refSale is null)
return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" });
if (refSale.Status != RetailSaleStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" });
}
var sale = new RetailSale
{
Number = number,
@ -220,6 +238,8 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard),
Notes = input.Notes,
IsReturn = input.IsReturn,
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
};
ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale);
@ -304,6 +324,9 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
// Валидация платежа: сумма наличных + по карте должна покрывать Total.
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
@ -400,6 +423,19 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
// Если у этого чека-продажи есть проведённые возвраты — нельзя отменить
// оригинал, пока возвраты не отменены/удалены (иначе QtyReturned оказался
// бы посчитан против несуществующего больше чека).
if (!sale.IsReturn)
{
var hasReturns = await _db.RetailSales.AnyAsync(
r => r.ReferenceSaleId == sale.Id && r.IsReturn && r.Status == RetailSaleStatus.Posted, ct);
if (hasReturns)
return Conflict(new { error = "У чека есть проведённые возвраты — сначала отмени их." });
}
if (sale.IsReturn) return await UnpostReturnAsync(sale, ct);
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
@ -421,6 +457,221 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
return NoContent();
}
/// <summary>POST /create-return — копирует строки проведённого чека в новый
/// Draft с IsReturn=true и ReferenceSaleId. Изначально quantity берётся как
/// (line.Quantity - line.QtyReturned) — оставшееся к возврату. Пользователь
/// потом может уменьшить/удалить позиции через обычный PUT.</summary>
[HttpPost("{id:guid}/create-return"), RequiresPermission("RetailSalesRefund")]
public async Task<ActionResult<RetailSaleDto>> CreateReturnFrom(Guid id, CancellationToken ct)
{
var src = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (src is null) return NotFound();
if (src.IsReturn) return BadRequest(new { error = "Нельзя создать возврат с возврата." });
if (src.Status != RetailSaleStatus.Posted) return BadRequest(new { error = "Сначала проведи исходный чек." });
var remainingByLine = src.Lines
.Select(l => new { l, remaining = l.Quantity - l.QtyReturned })
.Where(x => x.remaining > 0)
.ToList();
if (remainingByLine.Count == 0)
return BadRequest(new { error = "Этот чек уже полностью возвращён." });
var number = await GenerateNumberAsync(src.Date, ct);
var ret = new RetailSale
{
Number = number,
Date = DateTime.UtcNow,
Status = RetailSaleStatus.Draft,
StoreId = src.StoreId,
RetailPointId = src.RetailPointId,
CustomerId = src.CustomerId,
CurrencyId = src.CurrencyId,
Payment = src.Payment,
PaidCash = 0,
PaidCard = 0,
Notes = $"Возврат по чеку {src.Number}",
IsReturn = true,
ReferenceSaleId = src.Id,
};
var order = 0;
decimal subtotal = 0, discountTotal = 0;
foreach (var x in remainingByLine)
{
var qty = x.remaining;
var lineTotal = qty * x.l.UnitPrice - x.l.Discount;
ret.Lines.Add(new RetailSaleLine
{
ProductId = x.l.ProductId,
Quantity = qty,
UnitPrice = x.l.UnitPrice,
Discount = 0, // discount проще обнулить и считать «возвращаем по цене продажи»
LineTotal = qty * x.l.UnitPrice,
VatPercent = x.l.VatPercent,
SortOrder = order++,
});
subtotal += qty * x.l.UnitPrice;
}
ret.Subtotal = subtotal;
ret.DiscountTotal = discountTotal;
ret.Total = subtotal - discountTotal;
_db.RetailSales.Add(ret);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(ret.Id, ct);
return CreatedAtAction(nameof(Get), new { id = ret.Id }, dto);
}
/// <summary>Post return: ВОЗВРАЩАЕТ товар на склад (CustomerReturn +Quantity),
/// инкрементит QtyReturned на исходной строке (защита от over-return).</summary>
private async Task<IActionResult> PostReturnAsync(RetailSale sale, CancellationToken ct)
{
// Если есть reference — валидация over-return: для каждой строки возврата
// суммарное Quantity по продукту ≤ исходное (Quantity - QtyReturned).
if (sale.ReferenceSaleId is { } refId)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
var refByProduct = refLines
.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => new { Sold = g.Sum(x => x.Quantity), Returned = g.Sum(x => x.QtyReturned) });
var conflicts = new List<object>();
var returnByProduct = sale.Lines.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
foreach (var (productId, requested) in returnByProduct)
{
if (!refByProduct.TryGetValue(productId, out var rb))
{
conflicts.Add(new { productId, error = "товар не из исходного чека" });
continue;
}
var remaining = rb.Sold - rb.Returned;
if (requested > remaining)
{
var name = await _db.Products.Where(p => p.Id == productId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new { productId, productName = name, requested, remaining });
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Превышено количество доступное к возврату по этому чеку.",
lines = conflicts,
});
}
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: +line.Quantity, // товар возвращается на склад
Type: MovementType.CustomerReturn,
DocumentType: "customer-return",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: sale.Date), ct);
}
// Инкрементим QtyReturned на исходных строках (для защиты следующих возвратов).
if (sale.ReferenceSaleId is { } refId2)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId2).ToListAsync(ct);
foreach (var rl in refLines)
{
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
if (taken > 0)
{
// Если в исходном чеке у одного товара несколько строк — распределим
// увеличение QtyReturned последовательно: насыщаем по line.Quantity.
var available = rl.Quantity - rl.QtyReturned;
var take = Math.Min(taken, available);
rl.QtyReturned += take;
taken -= take;
}
}
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
}
return NoContent();
}
private async Task<IActionResult> UnpostReturnAsync(RetailSale sale, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: sale.StoreId,
Quantity: -line.Quantity,
Type: MovementType.CustomerReturn,
DocumentType: "customer-return-reversal",
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена возврата {sale.Number}"), ct);
}
if (sale.ReferenceSaleId is { } refId)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
foreach (var rl in refLines)
{
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
if (taken > 0)
{
var giveBack = Math.Min(taken, rl.QtyReturned);
rl.QtyReturned -= giveBack;
taken -= giveBack;
}
}
}
sale.Status = RetailSaleStatus.Draft;
sale.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
{
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
@ -484,9 +735,14 @@ private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken
orderby l.SortOrder
select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder))
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder,
l.QtyReturned))
.ToListAsync(ct);
string? refNumber = row.s.ReferenceSaleId is null ? null
: await _db.RetailSales.Where(rs => rs.Id == row.s.ReferenceSaleId)
.Select(rs => rs.Number).FirstOrDefaultAsync(ct);
return new RetailSaleDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.st.Id, row.st.Name,
@ -496,6 +752,7 @@ orderby l.SortOrder
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt,
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
lines);
}
}

View file

@ -50,6 +50,17 @@ public class RetailSale : TenantEntity
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
/// <summary>true — это документ возврата от покупателя (refund). Не уменьшает
/// остатки, а увеличивает их; деньги возвращаются покупателю (PaidCash/PaidCard
/// для return — сумма, отданная клиенту, для отчёта). Может быть привязан к
/// исходному чеку через <see cref="ReferenceSaleId"/>, может — стоять отдельно.</summary>
public bool IsReturn { get; set; }
/// <summary>Ссылка на исходный чек продажи, по которому формируется возврат.
/// Опционально (нулевая для возврата без чека).</summary>
public Guid? ReferenceSaleId { get; set; }
public RetailSale? ReferenceSale { get; set; }
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
}
@ -67,4 +78,10 @@ public class RetailSaleLine : TenantEntity
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
public decimal VatPercent { get; set; } // snapshot
public int SortOrder { get; set; }
/// <summary>Сколько единиц по этой строке возвращено (через документы CustomerReturn,
/// ссылающиеся на этот чек). Кешированная агрегация: контроллер инкрементит
/// при проведении return-документа. Защищает от over-return (нельзя вернуть
/// больше, чем продано).</summary>
public decimal QtyReturned { get; set; }
}

View file

@ -22,6 +22,7 @@ public static void ConfigureSales(this ModelBuilder b)
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ReferenceSale).WithMany().HasForeignKey(x => x.ReferenceSaleId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).OnDelete(DeleteBehavior.Cascade);
@ -29,6 +30,8 @@ public static void ConfigureSales(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId });
e.HasIndex(x => new { x.OrganizationId, x.IsReturn });
e.HasIndex(x => x.ReferenceSaleId);
});
b.Entity<RetailSaleLine>(e =>
@ -39,6 +42,7 @@ public static void ConfigureSales(this ModelBuilder b)
e.Property(x => x.Discount).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.Property(x => x.VatPercent).HasPrecision(5, 2);
e.Property(x => x.QtyReturned).HasPrecision(18, 4);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6e — расширение RetailSale возвратами от покупателя.
///
/// Добавляет колонки:
/// • retail_sales.IsReturn (bool) — флаг документа возврата;
/// • retail_sales.ReferenceSaleId (uuid?) — ссылка на исходный чек;
/// • retail_sale_lines.QtyReturned (numeric 18,4) — сколько уже возвращено
/// из исходной строки (агрегация для защиты от over-return).
///
/// Идемпотентно: все шаги через IF NOT EXISTS.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528040000_Phase6e_RetailSaleReturns")]
public partial class Phase6e_RetailSaleReturns : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='retail_sales'
AND column_name='IsReturn') THEN
ALTER TABLE public.retail_sales ADD COLUMN ""IsReturn"" boolean NOT NULL DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='retail_sales'
AND column_name='ReferenceSaleId') THEN
ALTER TABLE public.retail_sales ADD COLUMN ""ReferenceSaleId"" uuid;
ALTER TABLE public.retail_sales
ADD CONSTRAINT ""FK_retail_sales_retail_sales_ReferenceSaleId""
FOREIGN KEY (""ReferenceSaleId"") REFERENCES public.retail_sales(""Id"") ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='retail_sale_lines'
AND column_name='QtyReturned') THEN
ALTER TABLE public.retail_sale_lines ADD COLUMN ""QtyReturned"" numeric(18,4) NOT NULL DEFAULT 0;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ""IX_retail_sales_OrganizationId_IsReturn""
ON public.retail_sales (""OrganizationId"", ""IsReturn"");
CREATE INDEX IF NOT EXISTS ""IX_retail_sales_ReferenceSaleId""
ON public.retail_sales (""ReferenceSaleId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.retail_sale_lines DROP COLUMN IF EXISTS ""QtyReturned"";
ALTER TABLE public.retail_sales DROP CONSTRAINT IF EXISTS ""FK_retail_sales_retail_sales_ReferenceSaleId"";
DROP INDEX IF EXISTS public.""IX_retail_sales_OrganizationId_IsReturn"";
DROP INDEX IF EXISTS public.""IX_retail_sales_ReferenceSaleId"";
ALTER TABLE public.retail_sales DROP COLUMN IF EXISTS ""ReferenceSaleId"";
ALTER TABLE public.retail_sales DROP COLUMN IF EXISTS ""IsReturn"";
");
}
}
}

View file

@ -238,6 +238,7 @@ export interface RetailSaleListRow {
currencyId: string; currencyCode: string;
total: number; payment: PaymentMethod; lineCount: number;
postedAt: string | null;
isReturn: boolean; referenceSaleId: string | null; referenceSaleNumber: string | null;
}
export interface RetailSaleLineDto {
@ -245,6 +246,7 @@ export interface RetailSaleLineDto {
productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number;
qtyReturned: number;
}
export interface SalesStatsBucket {
@ -272,5 +274,6 @@ export interface RetailSaleDto {
subtotal: number; discountTotal: number; total: number;
payment: PaymentMethod; paidCash: number; paidCard: number;
notes: string | null; postedAt: string | null;
isReturn: boolean; referenceSaleId: string | null; referenceSaleNumber: string | null;
lines: RetailSaleLineDto[];
}

View file

@ -220,6 +220,19 @@ export function RetailSaleEditPage() {
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
{isPosted && existing.data && !existing.data.isReturn && (
<Button
type="button"
variant="secondary"
onClick={async () => {
if (!confirm('Создать возврат по этому чеку?')) return
const r = await api.post<RetailSaleDto>(`/api/sales/retail/${id}/create-return`)
navigate(`/sales/retail/${r.data.id}`)
}}
>
Создать возврат
</Button>
)}
{isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить

View file

@ -0,0 +1,136 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class CustomerReturnTests
{
private readonly ApiFactory _factory;
public CustomerReturnTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Создать продажу по чеку → создать возврат через create-return →
/// провести возврат → stock увеличился обратно.</summary>
[Fact]
public async Task Create_return_from_sale_returns_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"crt-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Оприходовать 10 шт.
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, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
// Продать 4 шт.
var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null,
customerId = (string?)null, currencyId = refs.CurrencyId,
payment = 0, paidCash = 1000m, paidCard = 0m,
lines = new[] { new { productId, quantity = 4m, unitPrice = 200m, discount = 0m, vatPercent = 12m } },
notes = "sale",
});
sale.EnsureSuccessStatusCode();
var saleId = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(6m);
// Создать возврат по чеку.
using var createRet = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/create-return", new { });
createRet.IsSuccessStatusCode.Should().BeTrue($"create-return вернул {(int)createRet.StatusCode}: {await createRet.Content.ReadAsStringAsync()}");
var ret = await createRet.Content.ReadFromJsonAsync<JsonElement>();
ret.GetProperty("isReturn").GetBoolean().Should().BeTrue();
ret.GetProperty("referenceSaleId").GetString().Should().Be(saleId);
ret.GetProperty("lines").GetArrayLength().Should().Be(1);
var retId = ret.GetProperty("id").GetString();
// Провести возврат → товар возвращается на склад.
using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{retId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post возврата вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Повторный create-return должен ругаться — всё уже возвращено.
using var second = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/create-return", new { });
((int)second.StatusCode).Should().Be(400);
}
[Fact]
public async Task Standalone_return_without_reference_works()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"crt-std-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Создать возврат без референса (просто возврат «с улицы»).
var resp = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null,
customerId = (string?)null, currencyId = refs.CurrencyId,
payment = 0, paidCash = 100m, paidCard = 0m,
lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
notes = "standalone-ret",
isReturn = true,
});
resp.EnsureSuccessStatusCode();
var retId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Провести → товар приходит на склад (даже если его там не было).
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{retId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(1m);
}
[Fact]
public async Task Cannot_unpost_sale_with_active_return()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"crt-unp-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, 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, quantity = 5m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null,
customerId = (string?)null, currencyId = refs.CurrencyId,
payment = 0, paidCash = 600m, paidCard = 0m,
lines = new[] { new { productId, quantity = 3m, unitPrice = 200m, discount = 0m, vatPercent = 12m } },
notes = "sale",
});
sale.EnsureSuccessStatusCode();
var saleId = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
var ret = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/create-return", new { });
ret.EnsureSuccessStatusCode();
var retId = (await ret.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{retId}/post", new { })).EnsureSuccessStatusCode();
// Теперь оригинал нельзя распровести — есть проведённый возврат.
using var unpost = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/unpost", new { });
((int)unpost.StatusCode).Should().Be(409);
}
}