feat(enters): оприходование товара без поставщика (P1-1)

Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.

Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:18:13 +05:00
parent 880be11bd8
commit e392bf8ae9
11 changed files with 1165 additions and 5 deletions

View file

@ -0,0 +1,386 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Оприходование (Enter) — постановка товара на склад без поставщика.
/// Используется при запуске учёта (начальные остатки) и для излишков
/// инвентаризации. Зеркалит <see cref="SuppliesController"/>, но проще:
/// нет SupplierId, нет пересчёта <c>Product.Cost</c> (UnitCost — балансовая
/// цена для отчёта).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/enters")]
public class EntersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public EntersController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record EnterListRow(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record EnterLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder);
public record EnterDto(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<EnterLineDto> Lines);
public record EnterLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record EnterInput(
DateTime Date, Guid StoreId, Guid CurrencyId,
string? Notes,
IReadOnlyList<EnterLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<EnterListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] EnterStatus? status,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from e in _db.Enters.AsNoTracking()
join st in _db.Stores on e.StoreId equals st.Id
join cu in _db.Currencies on e.CurrencyId equals cu.Id
select new { e, st, cu };
if (status is not null) q = q.Where(x => x.e.Status == status);
if (storeId is not null) q = q.Where(x => x.e.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.e.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.e.Number),
("number", true) => q.OrderByDescending(x => x.e.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.e.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.e.Date),
("status", false) => q.OrderBy(x => x.e.Status).ThenByDescending(x => x.e.Date),
("status", true) => q.OrderByDescending(x => x.e.Status).ThenByDescending(x => x.e.Date),
("total", false) => q.OrderBy(x => x.e.Total).ThenByDescending(x => x.e.Date),
("total", true) => q.OrderByDescending(x => x.e.Total).ThenByDescending(x => x.e.Date),
("date", false) => q.OrderBy(x => x.e.Date).ThenBy(x => x.e.Number),
_ => q.OrderByDescending(x => x.e.Date).ThenByDescending(x => x.e.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new EnterListRow(
x.e.Id, x.e.Number, x.e.Date, x.e.Status,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.e.Total,
x.e.Lines.Count,
x.e.PostedAt))
.ToListAsync(ct);
return new PagedResult<EnterListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EnterDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("EnterEdit")]
public async Task<ActionResult<EnterDto>> Create([FromBody] EnterInput 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 enter = new Enter
{
Number = number,
Date = input.Date,
Status = EnterStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
enter.Lines.Add(new EnterLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
enter.Total = enter.Lines.Sum(x => x.LineTotal);
_db.Enters.Add(enter);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(enter.Id, ct);
return CreatedAtAction(nameof(Get), new { id = enter.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("EnterEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] EnterInput 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 enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
enter.Date = input.Date;
enter.StoreId = input.StoreId;
enter.CurrencyId = input.CurrencyId;
enter.Notes = input.Notes;
_db.EnterLines.RemoveRange(enter.Lines);
enter.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
enter.Lines.Add(new EnterLine
{
EnterId = enter.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
enter.Total = enter.Lines.Sum(x => x.LineTotal);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Enters.Remove(enter);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("EnterEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status == EnterStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (enter.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in enter.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: enter.Date), ct);
}
enter.Status = EnterStatus.Posted;
enter.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("EnterEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: проверяем что текущий остаток позволяет «снять» оприходование
// без ухода в минус (часть товара может быть уже продана).
var reverseByProduct = enter.Lines
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == enter.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in reverseByProduct)
{
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,
reverseQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
lines = conflicts,
});
}
foreach (var line in enter.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter-reversal",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {enter.Number}"), ct);
}
enter.Status = EnterStatus.Draft;
enter.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.Enters
.Where(e => e.Number.StartsWith(prefix))
.OrderByDescending(e => e.Number)
.Select(e => e.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<EnterDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from e in _db.Enters.AsNoTracking()
join st in _db.Stores on e.StoreId equals st.Id
join cu in _db.Currencies on e.CurrencyId equals cu.Id
where e.Id == id
select new { e, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.EnterLines.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.EnterId == id
orderby l.SortOrder
select new EnterLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
.ToListAsync(ct);
return new EnterDto(
row.e.Id, row.e.Number, row.e.Date, row.e.Status,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.e.Notes,
row.e.Total, row.e.PostedAt,
lines);
}
}

View file

@ -0,0 +1,62 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum EnterStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Оприходование: документ постановки товара на склад БЕЗ поставщика.
/// Используется для начальных остатков (при запуске учёта), излишка по
/// результату инвентаризации, возврата товара из подразделения и т.п.
///
/// Отличается от <see cref="Supply"/>: нет SupplierId; total — не сумма закупки,
/// а сумма по UnitCost (стоимость оприходованного товара по балансовой цене).
/// При Post создаёт <see cref="Inventory.StockMovement"/> с типом
/// <see cref="Inventory.MovementType.Enter"/>.</summary>
public class Enter : TenantEntity
{
/// <summary>Уникальный в рамках организации номер документа (например "О-2026-000001").</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public EnterStatus Status { get; set; } = EnterStatus.Draft;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
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<EnterLine> Lines { get; set; } = new List<EnterLine>();
}
public class EnterLine : TenantEntity
{
public Guid EnterId { get; set; }
public Enter Enter { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
/// <summary>Балансовая цена единицы (по которой товар ставится на учёт).
/// Не пересчитывает <c>Product.Cost</c> — оприходование не образует
/// себестоимости (в отличие от приёмки).</summary>
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -44,6 +44,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
public DbSet<Enter> Enters => Set<Enter>();
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();

View file

@ -37,5 +37,34 @@ public static void ConfigurePurchases(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
b.Entity<Enter>(e =>
{
e.ToTable("enters");
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.Enter).HasForeignKey(l => l.EnterId).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 });
});
b.Entity<EnterLine>(e =>
{
e.ToTable("enter_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,82 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6a — оприходование (Enter).
///
/// Добавляет таблицы <c>enters</c> и <c>enter_lines</c> — документ
/// постановки товара на склад без поставщика (начальные остатки,
/// излишки инвентаризации). При проведении создаёт
/// <c>stock_movements</c> с типом <c>Enter</c>.
///
/// Структура зеркалит supplies/supply_lines, но без SupplierId и
/// без логики скользящего среднего себестоимости (UnitCost — балансовая
/// цена для отчёта, на Product.Cost не влияет).
///
/// Шаги идемпотентны через IF NOT EXISTS — позволяет применять на
/// стейджах, где таблица могла быть подсажена вручную.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528000000_Phase6a_Enters")]
public partial class Phase6a_Enters : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.enters (
""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,
""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_enters_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_enters_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_enters_OrganizationId_Number"" ON public.enters (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_enters_OrganizationId_Date"" ON public.enters (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_enters_OrganizationId_Status"" ON public.enters (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_enters_StoreId"" ON public.enters (""StoreId"");
CREATE INDEX IF NOT EXISTS ""IX_enters_CurrencyId"" ON public.enters (""CurrencyId"");
CREATE TABLE IF NOT EXISTS public.enter_lines (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""EnterId"" 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_enter_lines_enters_EnterId"" FOREIGN KEY (""EnterId"") REFERENCES public.enters(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_enter_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_enter_lines_EnterId"" ON public.enter_lines (""EnterId"");
CREATE INDEX IF NOT EXISTS ""IX_enter_lines_ProductId"" ON public.enter_lines (""ProductId"");
CREATE INDEX IF NOT EXISTS ""IX_enter_lines_OrganizationId_ProductId"" ON public.enter_lines (""OrganizationId"", ""ProductId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.enter_lines;
DROP TABLE IF EXISTS public.enters;
");
}
}
}

View file

@ -28,6 +28,8 @@ import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { EntersPage } from '@/pages/EntersPage'
import { EnterEditPage } from '@/pages/EnterEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout'
@ -100,6 +102,9 @@ export default function App() {
<Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
<Route path="/inventory/enters" element={<EntersPage />} />
<Route path="/inventory/enters/new" element={<EnterEditPage />} />
<Route path="/inventory/enters/:id" element={<EnterEditPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

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

View file

@ -112,6 +112,31 @@ export interface SupplyDto {
lines: SupplyLineDto[];
}
export const EnterStatus = { Draft: 0, Posted: 1 } as const
export type EnterStatus = (typeof EnterStatus)[keyof typeof EnterStatus]
export interface EnterListRow {
id: string; number: string; date: string; status: EnterStatus;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null;
}
export interface EnterLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitCost: number; lineTotal: number; sortOrder: number;
}
export interface EnterDto {
id: string; number: string; date: string; status: EnterStatus;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
notes: string | null;
total: number; postedAt: string | null;
lines: EnterLineDto[];
}
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]

View file

@ -0,0 +1,347 @@
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 { EnterStatus, type EnterDto, type Product } from '@/lib/types'
interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitCost: number
}
interface Form {
date: string
storeId: string
currencyId: string
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: '', notes: '', lines: [],
}
/** Минималистичная страница редактирования документа оприходования.
* Зеркалит SupplyEditPage но без поставщика и без UI розничных цен там
* балансовая цена единицы (UnitCost), которая не пересчитывает Product.Cost. */
export function EnterEditPage() {
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/enters', id],
queryFn: async () => (await api.get<EnterDto>(`/api/inventory/enters/${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,
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,
})),
})
}
}, [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 === EnterStatus.Draft
const isPosted = existing.data?.status === EnterStatus.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,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId, quantity: l.quantity, unitCost: l.unitCost,
})),
}
if (isNew) return (await api.post<EnterDto>('/api/inventory/enters', payload)).data
await api.put(`/api/inventory/enters/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/inventory/enters'] })
navigate(created ? `/inventory/enters/${created.id}` : `/inventory/enters/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/enters/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/enters'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/enters/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/enters'] })
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 remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/enters/${id}`) },
onSuccess: () => navigate('/inventory/enters'),
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 ?? p.referencePrice ?? 0,
}],
})
}
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/enters" 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>
{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-[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">
<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={4}>Итого</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,63 @@
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 EnterListRow, EnterStatus } from '@/lib/types'
const URL = '/api/inventory/enters'
export function EntersPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EnterListRow>(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/enters/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/enters/${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: '130px', sortKey: 'status', cell: (r) => (
r.status === EnterStatus.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: 'Склад', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', 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,156 @@
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 EnterPostUnpostTests
{
private readonly ApiFactory _factory;
public EnterPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Posting_enter_raises_stock_unpost_reverts()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"enter-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Draft enter: 7 шт по 50 (балансовая цена).
var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
currencyId = refs.CurrencyId,
notes = "начальный остаток",
lines = new[] { new { productId, quantity = 7m, unitCost = 50m } },
});
draftResp.EnsureSuccessStatusCode();
var enterId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Стартовый остаток 0.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
// Post → остаток 7. Cost не пересчитывается — оприходование не имеет «закупочной цены».
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m);
// Unpost → остаток обратно 0.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue($"unpost вернул {(int)unpost.StatusCode}: {await unpost.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
}
[Fact]
public async Task Double_post_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"enter-dbl-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
currencyId = refs.CurrencyId,
notes = "dbl",
lines = new[] { new { productId, quantity = 2m, unitCost = 30m } },
});
draftResp.EnsureSuccessStatusCode();
var enterId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
using var second = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { });
((int)second.StatusCode).Should().Be(409);
}
[Fact]
public async Task Tenant_isolation_enter()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"enter-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"enter-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/enters", new
{
date = DateTime.UtcNow,
storeId = refsA.StoreId,
currencyId = refsA.CurrencyId,
notes = "iso",
lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
});
resp.EnsureSuccessStatusCode();
var enterId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// B список не показывает документ A.
var bList = await b.ListAsync("/api/inventory/enters?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == enterId);
// B напрямую → 404.
using var direct = await b.Http.GetAsync($"/api/inventory/enters/{enterId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task Unpost_blocked_when_stock_would_go_negative()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"enter-neg-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Оприходовали 5 шт.
var create = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
currencyId = refs.CurrencyId,
notes = "stock-guard",
lines = new[] { new { productId, quantity = 5m, unitCost = 100m } },
});
create.EnsureSuccessStatusCode();
var enterId = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
// Списали 5 шт — оставшийся стартовый остаток = 0, при unpost ушёл бы в -5.
// Делаем розничную продажу 5 единиц и проводим.
// Сначала надо чтобы товар имел розничную цену — она задана при CreateProductAsync.
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, // Cash
paidCash = 1000m,
paidCard = 0m,
lines = new[] { new { productId, quantity = 5m, unitPrice = 200m, discount = 0m, vatPercent = 12m } },
notes = "drain",
});
sale.EnsureSuccessStatusCode();
var saleId = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
// Unpost оприходования должен быть отбит (остаток уйдёт в минус).
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/unpost", new { });
((int)unpost.StatusCode).Should().Be(409);
}
}