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? CustomerId, string? CustomerName,
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
decimal Total, PaymentMethod Payment, int LineCount, decimal Total, PaymentMethod Payment, int LineCount,
DateTime? PostedAt); DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber);
public record RetailSaleLineDto( public record RetailSaleLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, 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( public record RetailSaleDto(
Guid Id, string Number, DateTime Date, RetailSaleStatus Status, Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
@ -47,6 +49,7 @@ public record RetailSaleDto(
decimal Subtotal, decimal DiscountTotal, decimal Total, decimal Subtotal, decimal DiscountTotal, decimal Total,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard, PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt, string? Notes, DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
IReadOnlyList<RetailSaleLineDto> Lines); IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput( public record RetailSaleLineInput(
@ -61,7 +64,9 @@ public record RetailSaleInput(
[Range(0, 1e10)] decimal PaidCash, [Range(0, 1e10)] decimal PaidCash,
[Range(0, 1e10)] decimal PaidCard, [Range(0, 1e10)] decimal PaidCard,
string? Notes, string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines); IReadOnlyList<RetailSaleLineInput> Lines,
bool IsReturn = false,
Guid? ReferenceSaleId = null);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions); public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@ -182,7 +187,9 @@ public record SalesStatsResponse(
x.cu.Id, x.cu.Code, x.cu.Id, x.cu.Code,
x.s.Total, x.s.Payment, x.s.Total, x.s.Payment,
x.s.Lines.Count, 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); .ToListAsync(ct);
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; 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 number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); 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 var sale = new RetailSale
{ {
Number = number, Number = number,
@ -220,6 +238,8 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
PaidCash = R(input.PaidCash), PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard), PaidCard = R(input.PaidCard),
Notes = input.Notes, Notes = input.Notes,
IsReturn = input.IsReturn,
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
}; };
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale); _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.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." }); if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
// Валидация платежа: сумма наличных + по карте должна покрывать Total. // Валидация платежа: сумма наличных + по карте должна покрывать Total.
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса // Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге. // может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
@ -400,6 +423,19 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
if (sale is null) return NotFound(); if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." }); 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) foreach (var line in sale.Lines)
{ {
await _stock.ApplyMovementAsync(new StockMovementDraft( await _stock.ApplyMovementAsync(new StockMovementDraft(
@ -421,6 +457,221 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
return NoContent(); 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) private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
{ {
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); 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 orderby l.SortOrder
select new RetailSaleLineDto( select new RetailSaleLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name, 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); .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( return new RetailSaleDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status, row.s.Id, row.s.Number, row.s.Date, row.s.Status,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
@ -496,6 +752,7 @@ orderby l.SortOrder
row.s.Subtotal, row.s.DiscountTotal, row.s.Total, row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
row.s.Payment, row.s.PaidCash, row.s.PaidCard, row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt, row.s.Notes, row.s.PostedAt,
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
lines); lines);
} }
} }

View file

@ -50,6 +50,17 @@ public class RetailSale : TenantEntity
public DateTime? PostedAt { get; set; } public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { 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>(); 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 LineTotal { get; set; } // = Quantity * UnitPrice - Discount
public decimal VatPercent { get; set; } // snapshot public decimal VatPercent { get; set; } // snapshot
public int SortOrder { get; set; } 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.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.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.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); 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.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status }); e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId }); 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 => 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.Discount).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4); e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.Property(x => x.VatPercent).HasPrecision(5, 2); 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.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId }); 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; currencyId: string; currencyCode: string;
total: number; payment: PaymentMethod; lineCount: number; total: number; payment: PaymentMethod; lineCount: number;
postedAt: string | null; postedAt: string | null;
isReturn: boolean; referenceSaleId: string | null; referenceSaleNumber: string | null;
} }
export interface RetailSaleLineDto { export interface RetailSaleLineDto {
@ -245,6 +246,7 @@ export interface RetailSaleLineDto {
productName: string | null; productArticle: string | null; unitName: string | null; productName: string | null; productArticle: string | null; unitName: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number; quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number; vatPercent: number; sortOrder: number;
qtyReturned: number;
} }
export interface SalesStatsBucket { export interface SalesStatsBucket {
@ -272,5 +274,6 @@ export interface RetailSaleDto {
subtotal: number; discountTotal: number; total: number; subtotal: number; discountTotal: number; total: number;
payment: PaymentMethod; paidCash: number; paidCard: number; payment: PaymentMethod; paidCash: number; paidCard: number;
notes: string | null; postedAt: string | null; notes: string | null; postedAt: string | null;
isReturn: boolean; referenceSaleId: string | null; referenceSaleNumber: string | null;
lines: RetailSaleLineDto[]; lines: RetailSaleLineDto[];
} }

View file

@ -220,6 +220,19 @@ export function RetailSaleEditPage() {
</div> </div>
</div> </div>
<div className="flex gap-2 flex-shrink-0"> <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 && ( {isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}> <Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить <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);
}
}