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:
parent
561291f226
commit
ea29ab63c7
|
|
@ -1 +1 @@
|
|||
{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374}
|
||||
{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"";
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" /> Отменить
|
||||
|
|
|
|||
136
tests/food-market.IntegrationTests/CustomerReturnTests.cs
Normal file
136
tests/food-market.IntegrationTests/CustomerReturnTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue