feat(losses): списание со склада с указанием причины (P1-2)

Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).

Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:24:40 +05:00
parent 683d64dc9c
commit 3172b0ea72
11 changed files with 1163 additions and 1 deletions

View file

@ -0,0 +1,393 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Списание (Loss): уменьшение склада с указанием причины
/// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт
/// <see cref="StockMovement"/> тип <see cref="MovementType.WriteOff"/> с
/// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы
/// добавляем обратно — остаток всегда увеличится).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/losses")]
public class LossesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public LossesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record LossListRow(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record LossLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
decimal? StockAtStore);
public record LossDto(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<LossLineDto> Lines);
public record LossLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record LossInput(
DateTime Date, Guid StoreId, Guid CurrencyId, LossReason Reason,
string? Notes,
IReadOnlyList<LossLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<LossListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] LossStatus? status,
[FromQuery] LossReason? reason,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
select new { l, st, cu };
if (status is not null) q = q.Where(x => x.l.Status == status);
if (reason is not null) q = q.Where(x => x.l.Reason == reason);
if (storeId is not null) q = q.Where(x => x.l.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.l.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.l.Number),
("number", true) => q.OrderByDescending(x => x.l.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.l.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.l.Date),
("status", false) => q.OrderBy(x => x.l.Status).ThenByDescending(x => x.l.Date),
("status", true) => q.OrderByDescending(x => x.l.Status).ThenByDescending(x => x.l.Date),
("reason", false) => q.OrderBy(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("reason", true) => q.OrderByDescending(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("total", false) => q.OrderBy(x => x.l.Total).ThenByDescending(x => x.l.Date),
("total", true) => q.OrderByDescending(x => x.l.Total).ThenByDescending(x => x.l.Date),
("date", false) => q.OrderBy(x => x.l.Date).ThenBy(x => x.l.Number),
_ => q.OrderByDescending(x => x.l.Date).ThenByDescending(x => x.l.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new LossListRow(
x.l.Id, x.l.Number, x.l.Date, x.l.Status, x.l.Reason,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.l.Total,
x.l.Lines.Count,
x.l.PostedAt))
.ToListAsync(ct);
return new PagedResult<LossListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<LossDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("LossEdit")]
public async Task<ActionResult<LossDto>> Create([FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var loss = new Loss
{
Number = number,
Date = input.Date,
Status = LossStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Reason = input.Reason,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
loss.Lines.Add(new LossLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
loss.Total = loss.Lines.Sum(x => x.LineTotal);
_db.Losses.Add(loss);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(loss.Id, ct);
return CreatedAtAction(nameof(Get), new { id = loss.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
loss.Date = input.Date;
loss.StoreId = input.StoreId;
loss.CurrencyId = input.CurrencyId;
loss.Reason = input.Reason;
loss.Notes = input.Notes;
_db.LossLines.RemoveRange(loss.Lines);
loss.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
loss.Lines.Add(new LossLine
{
LossId = loss.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
loss.Total = loss.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Losses.Remove(loss);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода stock в минус.
var byProduct = loss.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
writeOffQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя списать больше, чем есть в наличии.",
lines = conflicts,
});
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: -line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: loss.Date,
Notes: $"reason={loss.Reason}"), ct);
}
loss.Status = LossStatus.Posted;
loss.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: возвращаем товар обратно — остаток только увеличится, проверки на минус не нужно.
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss-reversal",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {loss.Number}"), ct);
}
loss.Status = LossStatus.Draft;
loss.PostedAt = null;
await _db.SaveChangesAsync(ct);
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 async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"С-{year}-";
var lastNumber = await _db.Losses
.Where(l => l.Number.StartsWith(prefix))
.OrderByDescending(l => l.Number)
.Select(l => l.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<LossDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
where l.Id == id
select new { l, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.LossLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.LossId == id
orderby l.SortOrder
select new LossLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.l.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new LossDto(
row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.l.Notes,
row.l.Total, row.l.PostedAt,
lines);
}
}

View file

@ -0,0 +1,72 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum LossStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Причина списания. Из этого классификатора строятся ABC/XYZ-отчёты
/// и фискальная отчётность (порча/просрочка) в KZ. Не строй наценочную логику
/// поверх этих значений — используй ровно как business reason.</summary>
public enum LossReason
{
/// <summary>Брак (производственный/транспортный).</summary>
Defect = 0,
/// <summary>Просрочка / истёк срок годности.</summary>
Expired = 1,
/// <summary>Повреждение (механическое, при хранении/перевозке).</summary>
Damage = 2,
/// <summary>Недостача (выявлена инвентаризацией; для оформления свыше — отдельный документ инвентаризации).</summary>
Shortage = 3,
/// <summary>Прочая причина (в комментарии).</summary>
Other = 99,
}
/// <summary>Документ списания со склада: брак, просрочка, недостача, повреждение.
/// При проведении создаёт <see cref="StockMovement"/> с типом
/// <see cref="MovementType.WriteOff"/> и отрицательным Quantity.</summary>
public class Loss : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public LossStatus Status { get; set; } = LossStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public LossReason Reason { get; set; } = LossReason.Other;
public string? Notes { get; set; }
/// <summary>Сумма по строкам = Σ Quantity·UnitCost. Балансовая стоимость списания.</summary>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
}
public class LossLine : TenantEntity
{
public Guid LossId { get; set; }
public Loss Loss { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (снимок Product.Cost на момент создания строки;
/// можно переопределить). Используется в Total и в отчётности.</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -47,6 +47,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Enter> Enters => Set<Enter>(); public DbSet<Enter> Enters => Set<Enter>();
public DbSet<EnterLine> EnterLines => Set<EnterLine>(); public DbSet<EnterLine> EnterLines => Set<EnterLine>();
public DbSet<Loss> Losses => Set<Loss>();
public DbSet<LossLine> LossLines => Set<LossLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>(); public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>(); public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();

View file

@ -37,5 +37,35 @@ public static void ConfigureInventory(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.OccurredAt }); e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.OccurredAt });
e.HasIndex(x => new { x.DocumentType, x.DocumentId }); e.HasIndex(x => new { x.DocumentType, x.DocumentId });
}); });
b.Entity<Loss>(e =>
{
e.ToTable("losses");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Total).HasPrecision(18, 4);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.Loss).HasForeignKey(l => l.LossId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.Reason });
});
b.Entity<LossLine>(e =>
{
e.ToTable("loss_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitCost).HasPrecision(18, 4);
e.Property(x => x.LineTotal).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,79 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6b — списание (Loss).
///
/// Документ списания со склада с указанием причины (LossReason): брак,
/// просрочка, повреждение, недостача, прочее. При проведении создаёт
/// <c>stock_movements</c> с типом <c>WriteOff</c> и отрицательным
/// Quantity.
///
/// Шаги идемпотентны через IF NOT EXISTS.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528010000_Phase6b_Losses")]
public partial class Phase6b_Losses : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.losses (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""Status"" integer NOT NULL,
""StoreId"" uuid NOT NULL,
""CurrencyId"" uuid NOT NULL,
""Reason"" integer NOT NULL,
""Notes"" varchar(1000),
""Total"" numeric(18,4) NOT NULL,
""PostedAt"" timestamp with time zone,
""PostedByUserId"" uuid,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_losses_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_losses_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_losses_OrganizationId_Number"" ON public.losses (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_losses_OrganizationId_Date"" ON public.losses (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_losses_OrganizationId_Status"" ON public.losses (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_losses_OrganizationId_Reason"" ON public.losses (""OrganizationId"", ""Reason"");
CREATE INDEX IF NOT EXISTS ""IX_losses_StoreId"" ON public.losses (""StoreId"");
CREATE INDEX IF NOT EXISTS ""IX_losses_CurrencyId"" ON public.losses (""CurrencyId"");
CREATE TABLE IF NOT EXISTS public.loss_lines (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""LossId"" uuid NOT NULL,
""ProductId"" uuid NOT NULL,
""Quantity"" numeric(18,4) NOT NULL,
""UnitCost"" numeric(18,4) NOT NULL,
""LineTotal"" numeric(18,4) NOT NULL,
""SortOrder"" integer NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_loss_lines_losses_LossId"" FOREIGN KEY (""LossId"") REFERENCES public.losses(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_loss_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_loss_lines_LossId"" ON public.loss_lines (""LossId"");
CREATE INDEX IF NOT EXISTS ""IX_loss_lines_ProductId"" ON public.loss_lines (""ProductId"");
CREATE INDEX IF NOT EXISTS ""IX_loss_lines_OrganizationId_ProductId"" ON public.loss_lines (""OrganizationId"", ""ProductId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.loss_lines;
DROP TABLE IF EXISTS public.losses;
");
}
}
}

View file

@ -30,6 +30,8 @@ import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage' import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { EntersPage } from '@/pages/EntersPage' import { EntersPage } from '@/pages/EntersPage'
import { EnterEditPage } from '@/pages/EnterEditPage' import { EnterEditPage } from '@/pages/EnterEditPage'
import { LossesPage } from '@/pages/LossesPage'
import { LossEditPage } from '@/pages/LossEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
@ -105,6 +107,9 @@ export default function App() {
<Route path="/inventory/enters" element={<EntersPage />} /> <Route path="/inventory/enters" element={<EntersPage />} />
<Route path="/inventory/enters/new" element={<EnterEditPage />} /> <Route path="/inventory/enters/new" element={<EnterEditPage />} />
<Route path="/inventory/enters/:id" element={<EnterEditPage />} /> <Route path="/inventory/enters/:id" element={<EnterEditPage />} />
<Route path="/inventory/losses" element={<LossesPage />} />
<Route path="/inventory/losses/new" element={<LossEditPage />} />
<Route path="/inventory/losses/:id" element={<LossEditPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -86,6 +86,7 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isStorekeeper) { if (isAdmin || isStorekeeper) {
stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' }) stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' }) stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' })
} }
sections.push({ group: 'Остатки', items: stock }) sections.push({ group: 'Остатки', items: stock })
} }

View file

@ -137,6 +137,42 @@ export interface EnterDto {
lines: EnterLineDto[]; lines: EnterLineDto[];
} }
export const LossStatus = { Draft: 0, Posted: 1 } as const
export type LossStatus = (typeof LossStatus)[keyof typeof LossStatus]
export const LossReason = { Defect: 0, Expired: 1, Damage: 2, Shortage: 3, Other: 99 } as const
export type LossReason = (typeof LossReason)[keyof typeof LossReason]
export const lossReasonLabel: Record<LossReason, string> = {
[LossReason.Defect]: 'Брак',
[LossReason.Expired]: 'Просрочка',
[LossReason.Damage]: 'Повреждение',
[LossReason.Shortage]: 'Недостача',
[LossReason.Other]: 'Прочее',
}
export interface LossListRow {
id: string; number: string; date: string; status: LossStatus; reason: LossReason;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null;
}
export interface LossLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitCost: number; lineTotal: number; sortOrder: number;
stockAtStore: number | null;
}
export interface LossDto {
id: string; number: string; date: string; status: LossStatus; reason: LossReason;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
notes: string | null;
total: number; postedAt: string | null;
lines: LossLineDto[];
}
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus] export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]

View file

@ -0,0 +1,362 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { LossStatus, LossReason, lossReasonLabel, type LossDto, type Product } from '@/lib/types'
interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitCost: number
stockAtStore: number | null
}
interface Form {
date: string
storeId: string
currencyId: string
reason: LossReason
notes: string
lines: LineRow[]
}
const todayIso = () => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const emptyForm: Form = {
date: todayIso(), storeId: '', currencyId: '', reason: LossReason.Other, notes: '', lines: [],
}
export function LossEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/inventory/losses', id],
queryFn: async () => (await api.get<LossDto>(`/api/inventory/losses/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
storeId: s.storeId,
currencyId: s.currencyId,
reason: s.reason,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitCost: l.unitCost,
stockAtStore: l.stockAtStore,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
if (isNew) {
if (!form.storeId && stores.data?.length) {
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
? currencies.data.find((c) => c.id === org.data!.defaultCurrencyId)
: currencies.data.find((c) => c.code === 'KZT')
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
}
}
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === LossStatus.Draft
const isPosted = existing.data?.status === LossStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitCost
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
storeId: form.storeId,
currencyId: form.currencyId,
reason: form.reason,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId, quantity: l.quantity, unitCost: l.unitCost,
})),
}
if (isNew) return (await api.post<LossDto>('/api/inventory/losses', payload)).data
await api.put(`/api/inventory/losses/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/inventory/losses'] })
navigate(created ? `/inventory/losses/${created.id}` : `/inventory/losses/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/losses/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/losses'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/losses/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/losses'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/losses/${id}`) },
onSuccess: () => navigate('/inventory/losses'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitName,
quantity: 1,
unitCost: p.cost ?? 0,
stockAtStore: null,
}],
})
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.date && !!form.storeId && !!form.currencyId
&& form.lines.length > 0 && isDraft
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/inventory/losses" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списан, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Причина *">
<Select value={String(form.reason)} disabled={isPosted}
onChange={(e) => setForm({ ...form, reason: Number(e.target.value) as LossReason })}>
{Object.entries(lossReasonLabel).map(([v, lbl]) => (
<option key={v} value={v}>{lbl}</option>
))}
</Select>
</Field>
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
)}
<Field label="Комментарий" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Детали (партия, накладная, акт)…" />
</Field>
</div>
{!isNew && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
onChange={(v) => {
if (v) {
if (confirm('Провести? Товар будет списан со склада.')) post.mutate()
} else {
if (confirm('Снять проведение? Списанный товар вернётся на остаток.')) unpost.mutate()
}
}}
/>
</div>
)}
</section>
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium text-slate-900 dark:text-slate-100">Позиции</h2>
{!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить из справочника
</Button>
)}
</div>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-500 py-6 text-center">Нет позиций.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[80px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[100px] text-right">Остаток</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[120px] text-right">Списать</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[140px] text-right">Цена ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[140px] text-right">Сумма</th>
<th className="w-8"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium text-slate-800 dark:text-slate-100">{l.productName || '(без названия)'}</div>
{l.productArticle && <div className="text-xs text-slate-500">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol ?? '—'}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">
{l.stockAtStore != null ? l.stockAtStore.toLocaleString('ru') : '—'}
</td>
<td className="py-2 px-3 text-right">
<NumberInput value={l.quantity} disabled={isPosted}
onChange={(v) => updateLine(i, { quantity: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right">
<MoneyInput value={l.unitCost} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => updateLine(i, { unitCost: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right font-mono">
{lineTotal(l).toLocaleString('ru', moneyFmt)}
</td>
<td className="py-2 px-1">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)}
className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
<tr className="font-medium">
<td className="py-3 pr-3" colSpan={5}>Итого</td>
<td className="py-3 px-3 text-right font-mono">
{grandTotal.toLocaleString('ru', moneyFmt)} {existing.data?.currencyCode ?? ''}
</td>
<td />
</tr>
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
<ProductPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
/>
</form>
)
}

View file

@ -0,0 +1,64 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type LossListRow, LossStatus, lossReasonLabel } from '@/lib/types'
const URL = '/api/inventory/losses'
export function LossesPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<LossListRow>(URL)
const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
return (
<ListPageShell
title="Списания"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Брак, просрочка, повреждения, недостача.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/losses/new">
<Button><Plus className="w-4 h-4" /> Новое списание</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => navigate(`/inventory/losses/${r.id}`)}
columns={[
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === LossStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Причина', width: '140px', sortKey: 'reason', cell: (r) => lossReasonLabel[r.reason] ?? r.reason },
{ header: 'Склад', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Списаний пока нет."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,117 @@
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 LossPostUnpostTests
{
private readonly ApiFactory _factory;
public LossPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Положить через Enter, потом списать, проверить остаток и обратимость.</summary>
[Fact]
public async Task Posting_loss_decrements_stock_unpost_restores()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"loss-{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 { }))
.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Списываем 3 шт с причиной Expired.
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
reason = 1, // Expired
notes = "просрочка партии",
lines = new[] { new { productId, quantity = 3m, unitCost = 50m } },
});
loss.EnsureSuccessStatusCode();
var lossId = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m);
// Unpost → возвращаем на склад.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
}
[Fact]
public async Task Cannot_write_off_more_than_available()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"loss-over-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// 2 шт на остатке.
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed-small", lines = new[] { new { productId, quantity = 2m, 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();
// Списать 5 шт — больше чем есть.
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0,
notes = "over", lines = new[] { new { productId, quantity = 5m, unitCost = 50m } },
});
loss.EnsureSuccessStatusCode();
var lossId = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { });
((int)post.StatusCode).Should().Be(409);
// Остаток не должен измениться.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(2m);
}
[Fact]
public async Task Tenant_isolation_loss()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"loss-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"loss-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var resp = await a.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, reason = 0,
notes = "iso", lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
});
resp.EnsureSuccessStatusCode();
var lossId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/losses?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == lossId);
using var direct = await b.Http.GetAsync($"/api/inventory/losses/{lossId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}