From ea29ab63c74fa899555c261c805981ab6863dff5 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 09:51:04 +0500 Subject: [PATCH] =?UTF-8?q?feat(returns):=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=82=20=D0=BE=D1=82=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20(CustomerReturn)=20(P1-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расширение 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 --- .claude/scheduled_tasks.lock | 2 +- .../Sales/RetailSalesController.cs | 267 +++++++++++++++++- src/food-market.domain/Sales/RetailSale.cs | 17 ++ .../Configurations/SalesConfigurations.cs | 4 + ...0260528040000_Phase6e_RetailSaleReturns.cs | 68 +++++ src/food-market.web/src/lib/types.ts | 3 + .../src/pages/RetailSaleEditPage.tsx | 13 + .../CustomerReturnTests.cs | 136 +++++++++ 8 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260528040000_Phase6e_RetailSaleReturns.cs create mode 100644 tests/food-market.IntegrationTests/CustomerReturnTests.cs diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 9df4eab..5ad4d54 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"791848bb-ad06-4ccc-9c5a-13da4ee36524","pid":353363,"acquiredAt":1776883641374} \ No newline at end of file +{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096} \ No newline at end of file diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index ca54243..a37ebc3 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -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 Lines); public record RetailSaleLineInput( @@ -61,7 +64,9 @@ public record RetailSaleInput( [Range(0, 1e10)] decimal PaidCash, [Range(0, 1e10)] decimal PaidCard, string? Notes, - IReadOnlyList Lines); + IReadOnlyList 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; @@ -207,6 +214,17 @@ public async Task> 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> 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 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 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 Unpost(Guid id, CancellationToken ct) return NoContent(); } + /// POST /create-return — копирует строки проведённого чека в новый + /// Draft с IsReturn=true и ReferenceSaleId. Изначально quantity берётся как + /// (line.Quantity - line.QtyReturned) — оставшееся к возврату. Пользователь + /// потом может уменьшить/удалить позиции через обычный PUT. + [HttpPost("{id:guid}/create-return"), RequiresPermission("RetailSalesRefund")] + public async Task> 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); + } + + /// Post return: ВОЗВРАЩАЕТ товар на склад (CustomerReturn +Quantity), + /// инкрементит QtyReturned на исходной строке (защита от over-return). + private async Task 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(); + 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 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 input, bool allowFractional) { decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); @@ -484,9 +735,14 @@ private async Task 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); } } diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index 8bdd4c6..f096b39 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -50,6 +50,17 @@ public class RetailSale : TenantEntity public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } + /// true — это документ возврата от покупателя (refund). Не уменьшает + /// остатки, а увеличивает их; деньги возвращаются покупателю (PaidCash/PaidCard + /// для return — сумма, отданная клиенту, для отчёта). Может быть привязан к + /// исходному чеку через , может — стоять отдельно. + public bool IsReturn { get; set; } + + /// Ссылка на исходный чек продажи, по которому формируется возврат. + /// Опционально (нулевая для возврата без чека). + public Guid? ReferenceSaleId { get; set; } + public RetailSale? ReferenceSale { get; set; } + public ICollection Lines { get; set; } = new List(); } @@ -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; } + + /// Сколько единиц по этой строке возвращено (через документы CustomerReturn, + /// ссылающиеся на этот чек). Кешированная агрегация: контроллер инкрементит + /// при проведении return-документа. Защищает от over-return (нельзя вернуть + /// больше, чем продано). + public decimal QtyReturned { get; set; } } diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index 3e1f101..594a504 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -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(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 }); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260528040000_Phase6e_RetailSaleReturns.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528040000_Phase6e_RetailSaleReturns.cs new file mode 100644 index 0000000..999a339 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528040000_Phase6e_RetailSaleReturns.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase6e — расширение RetailSale возвратами от покупателя. + /// + /// Добавляет колонки: + /// • retail_sales.IsReturn (bool) — флаг документа возврата; + /// • retail_sales.ReferenceSaleId (uuid?) — ссылка на исходный чек; + /// • retail_sale_lines.QtyReturned (numeric 18,4) — сколько уже возвращено + /// из исходной строки (агрегация для защиты от over-return). + /// + /// Идемпотентно: все шаги через IF NOT EXISTS. + [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""; + "); + } + } +} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 2298e8e..7c9d8b2 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -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[]; } diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index bada279..2931db1 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -220,6 +220,19 @@ export function RetailSaleEditPage() {
+ {isPosted && existing.data && !existing.data.isReturn && ( + + )} {isPosted && (