feat(demands): оптовая отгрузка контрагенту-юрлицу (P1-5)

Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.

EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.

ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).

Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.

Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.

Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 16:18:49 +05:00
parent 602c0579ec
commit 47a019dc6d
11 changed files with 1227 additions and 1 deletions

View file

@ -0,0 +1,402 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Sales;
/// <summary>Оптовая отгрузка (Demand). Списывает товар со склада в адрес
/// юрлица-контрагента. Зеркалит <see cref="RetailSalesController"/>, но
/// без RetailPoint/Cashier и с другим способом оплаты (Credit вместо Mixed/Bonus).
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
[ApiController]
[Authorize]
[Route("api/sales/demands")]
public class DemandsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public DemandsController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record DemandListRow(
Guid Id, string Number, DateTime Date, DemandStatus Status,
Guid CustomerId, string CustomerName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, decimal PaidAmount,
DemandPayment Payment, int LineCount,
DateTime? PostedAt);
public record DemandLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal,
decimal VatPercent, int SortOrder,
decimal? StockAtStore);
public record DemandDto(
Guid Id, string Number, DateTime Date, DemandStatus Status,
Guid CustomerId, string CustomerName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
DemandPayment Payment, decimal Subtotal, decimal DiscountTotal,
decimal Total, decimal PaidAmount,
string? Notes, DateTime? PostedAt,
IReadOnlyList<DemandLineDto> Lines);
public record DemandLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitPrice,
[Range(0, 1e10)] decimal Discount,
[Range(0, 100)] decimal VatPercent);
public record DemandInput(
DateTime Date,
Guid CustomerId, Guid StoreId, Guid CurrencyId,
DemandPayment Payment,
[Range(0, 1e10)] decimal PaidAmount,
string? Notes,
IReadOnlyList<DemandLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<DemandListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] DemandStatus? status,
[FromQuery] Guid? customerId,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from d in _db.Demands.AsNoTracking()
join cp in _db.Counterparties on d.CustomerId equals cp.Id
join st in _db.Stores on d.StoreId equals st.Id
join cu in _db.Currencies on d.CurrencyId equals cu.Id
select new { d, cp, st, cu };
if (status is not null) q = q.Where(x => x.d.Status == status);
if (customerId is not null) q = q.Where(x => x.d.CustomerId == customerId);
if (storeId is not null) q = q.Where(x => x.d.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.d.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.d.Number),
("number", true) => q.OrderByDescending(x => x.d.Number),
("customer", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.d.Date),
("customer", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.d.Date),
("status", false) => q.OrderBy(x => x.d.Status).ThenByDescending(x => x.d.Date),
("status", true) => q.OrderByDescending(x => x.d.Status).ThenByDescending(x => x.d.Date),
("total", false) => q.OrderBy(x => x.d.Total).ThenByDescending(x => x.d.Date),
("total", true) => q.OrderByDescending(x => x.d.Total).ThenByDescending(x => x.d.Date),
("date", false) => q.OrderBy(x => x.d.Date).ThenBy(x => x.d.Number),
_ => q.OrderByDescending(x => x.d.Date).ThenByDescending(x => x.d.Number),
};
var items = await q.Skip(req.Skip).Take(req.Take)
.Select(x => new DemandListRow(
x.d.Id, x.d.Number, x.d.Date, x.d.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.d.Total, x.d.PaidAmount, x.d.Payment, x.d.Lines.Count,
x.d.PostedAt))
.ToListAsync(ct);
return new PagedResult<DemandListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<DemandDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("DemandsEdit")]
public async Task<ActionResult<DemandDto>> Create([FromBody] DemandInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.CustomerId), input.CustomerId),
(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 demand = new Demand
{
Number = number,
Date = input.Date,
Status = DemandStatus.Draft,
CustomerId = input.CustomerId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Payment = input.Payment,
PaidAmount = input.PaidAmount,
Notes = input.Notes,
};
ApplyLines(demand, input.Lines);
_db.Demands.Add(demand);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(demand.Id, ct);
return CreatedAtAction(nameof(Get), new { id = demand.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("Customer") ? "customerId"
: 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("DemandsEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] DemandInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.CustomerId), input.CustomerId),
(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 demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
demand.Date = input.Date;
demand.CustomerId = input.CustomerId;
demand.StoreId = input.StoreId;
demand.CurrencyId = input.CurrencyId;
demand.Payment = input.Payment;
demand.PaidAmount = input.PaidAmount;
demand.Notes = input.Notes;
// Удаляем старые строки через ExecuteDelete (минует трекер), добавляем новые
// напрямую в DbSet — тот же паттерн что в RetailSale.Update (см. P1-8 fix).
await _db.DemandLines.Where(l => l.DemandId == demand.Id).ExecuteDeleteAsync(ct);
ApplyLines(demand, input.Lines);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("DemandsEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Demands.Remove(demand);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("DemandsPost")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода в минус.
var byProduct = demand.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == demand.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var x in byProduct)
{
stocks.TryGetValue(x.ProductId, out var avail);
if (avail < x.Qty)
{
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = x.ProductId, productName = name,
requested = x.Qty, available = avail,
});
}
}
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 demand.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: demand.StoreId,
Quantity: -line.Quantity,
Type: MovementType.WholesaleSale,
DocumentType: "demand",
DocumentId: demand.Id,
DocumentNumber: demand.Number,
UnitCost: line.UnitPrice,
OccurredAt: demand.Date), ct);
}
demand.Status = DemandStatus.Posted;
demand.PostedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("demand", "serialization");
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
}
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("demand");
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("DemandsPost")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demand is null) return NotFound();
if (demand.Status != DemandStatus.Posted) return Conflict(new { error = "Документ не проведён." });
foreach (var line in demand.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: demand.StoreId,
Quantity: line.Quantity,
Type: MovementType.WholesaleSale,
DocumentType: "demand-reversal",
DocumentId: demand.Id,
DocumentNumber: demand.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена отгрузки {demand.Number}"), ct);
}
demand.Status = DemandStatus.Draft;
demand.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 void ApplyLines(Demand demand, IReadOnlyList<DemandLineInput> input)
{
var order = 0;
decimal subtotal = 0m, discountTotal = 0m;
foreach (var l in input)
{
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
// Прямой Add в DbSet — тот же паттерн что в RetailSale.ApplyLines:
// через nav-collection EF8 в комбинации с client-side Id путается.
_db.DemandLines.Add(new DemandLine
{
DemandId = demand.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = l.Discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * l.UnitPrice;
discountTotal += l.Discount;
}
demand.Subtotal = subtotal;
demand.DiscountTotal = discountTotal;
demand.Total = subtotal - discountTotal;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var prefix = $"ОТГ-{date.Year}-";
var lastNumber = await _db.Demands
.Where(d => d.Number.StartsWith(prefix))
.OrderByDescending(d => d.Number)
.Select(d => d.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<DemandDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from d in _db.Demands.AsNoTracking()
join cp in _db.Counterparties on d.CustomerId equals cp.Id
join st in _db.Stores on d.StoreId equals st.Id
join cu in _db.Currencies on d.CurrencyId equals cu.Id
where d.Id == id
select new { d, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.DemandLines.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.DemandId == id
orderby l.SortOrder
select new DemandLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal,
l.VatPercent, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.d.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
return new DemandDto(
row.d.Id, row.d.Number, row.d.Date, row.d.Status,
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.d.Payment, row.d.Subtotal, row.d.DiscountTotal,
row.d.Total, row.d.PaidAmount,
row.d.Notes, row.d.PostedAt,
lines);
}
}

View file

@ -0,0 +1,77 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
public enum DemandStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Оптовая отгрузка контрагенту-юрлицу. По MoySklad — «Отгрузка».
/// Отличие от <see cref="RetailSale"/>: всегда юрлицо (CounterpartyId
/// обязателен), способ оплаты — нал/безнал/в кредит, цена и НДС могут
/// отличаться от розничных (тип цен «Опт.»). При проведении создаёт
/// <see cref="Inventory.StockMovement"/> тип
/// <see cref="Inventory.MovementType.WholesaleSale"/> с -Quantity.</summary>
public class Demand : TenantEntity
{
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public DemandStatus Status { get; set; } = DemandStatus.Draft;
public Guid CustomerId { get; set; }
public Counterparty Customer { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
/// <summary>0=Cash, 1=Card, 2=BankTransfer, 3=Credit (постоплата), 99=Mixed.
/// Совпадает с <see cref="PaymentMethod"/> + добавлен Credit.</summary>
public DemandPayment Payment { get; set; } = DemandPayment.BankTransfer;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal Total { get; set; }
/// <summary>Сумма оплаченного по этой отгрузке (для отслеживания дебиторки).
/// Может быть меньше Total — тогда остаток за контрагентом (Total PaidAmount).
/// На MVP не строим отчёт по задолженности — просто сохраняем.</summary>
public decimal PaidAmount { get; set; }
public string? Notes { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<DemandLine> Lines { get; set; } = new List<DemandLine>();
}
public enum DemandPayment
{
Cash = 0,
Card = 1,
BankTransfer = 2,
Credit = 3, // постоплата, дебиторка
Mixed = 99,
}
public class DemandLine : TenantEntity
{
public Guid DemandId { get; set; }
public Demand Demand { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public decimal LineTotal { get; set; }
public decimal VatPercent { get; set; }
public int SortOrder { get; set; }
}

View file

@ -63,6 +63,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>(); public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>(); public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>();
public DbSet<Demand> Demands => Set<Demand>();
public DbSet<DemandLine> DemandLines => Set<DemandLine>();
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>(); public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();

View file

@ -59,5 +59,40 @@ public static void ConfigureSales(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique(); e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique();
e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks
}); });
b.Entity<Demand>(e =>
{
e.ToTable("demands");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Subtotal).HasPrecision(18, 4);
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
e.Property(x => x.Total).HasPrecision(18, 4);
e.Property(x => x.PaidAmount).HasPrecision(18, 4);
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
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.Demand).HasForeignKey(l => l.DemandId).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.CustomerId });
});
b.Entity<DemandLine>(e =>
{
e.ToTable("demand_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
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.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,83 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase8a — оптовая отгрузка (Demand).
///
/// По MoySklad: «Отгрузка контрагенту». Зеркалит RetailSale но всегда с
/// юрлицом-контрагентом, способ оплаты включает Credit (постоплата).
/// При проведении создаёт stock_movements тип WholesaleSale (=3) с -Quantity.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528200000_Phase8a_Demands")]
public partial class Phase8a_Demands : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.demands (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""Number"" varchar(50) NOT NULL,
""Date"" timestamp with time zone NOT NULL,
""Status"" integer NOT NULL,
""CustomerId"" uuid NOT NULL,
""StoreId"" uuid NOT NULL,
""CurrencyId"" uuid NOT NULL,
""Payment"" integer NOT NULL,
""Subtotal"" numeric(18,4) NOT NULL,
""DiscountTotal"" numeric(18,4) NOT NULL,
""Total"" numeric(18,4) NOT NULL,
""PaidAmount"" numeric(18,4) NOT NULL DEFAULT 0,
""Notes"" varchar(1000),
""PostedAt"" timestamp with time zone,
""PostedByUserId"" uuid,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_demands_counterparties_CustomerId"" FOREIGN KEY (""CustomerId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_demands_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT,
CONSTRAINT ""FK_demands_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Number"" ON public.demands (""OrganizationId"", ""Number"");
CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Date"" ON public.demands (""OrganizationId"", ""Date"");
CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Status"" ON public.demands (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_CustomerId"" ON public.demands (""OrganizationId"", ""CustomerId"");
CREATE INDEX IF NOT EXISTS ""IX_demands_StoreId"" ON public.demands (""StoreId"");
CREATE INDEX IF NOT EXISTS ""IX_demands_CurrencyId"" ON public.demands (""CurrencyId"");
CREATE TABLE IF NOT EXISTS public.demand_lines (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""DemandId"" uuid NOT NULL,
""ProductId"" uuid NOT NULL,
""Quantity"" numeric(18,4) NOT NULL,
""UnitPrice"" numeric(18,4) NOT NULL,
""Discount"" numeric(18,4) NOT NULL,
""LineTotal"" numeric(18,4) NOT NULL,
""VatPercent"" numeric(5,2) NOT NULL,
""SortOrder"" integer NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone,
CONSTRAINT ""FK_demand_lines_demands_DemandId"" FOREIGN KEY (""DemandId"") REFERENCES public.demands(""Id"") ON DELETE CASCADE,
CONSTRAINT ""FK_demand_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ""IX_demand_lines_DemandId"" ON public.demand_lines (""DemandId"");
CREATE INDEX IF NOT EXISTS ""IX_demand_lines_ProductId"" ON public.demand_lines (""ProductId"");
CREATE INDEX IF NOT EXISTS ""IX_demand_lines_OrganizationId_ProductId"" ON public.demand_lines (""OrganizationId"", ""ProductId"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.demand_lines;
DROP TABLE IF EXISTS public.demands;
");
}
}
}

View file

@ -38,6 +38,8 @@ import { InventoriesPage } from '@/pages/InventoriesPage'
import { InventoryEditPage } from '@/pages/InventoryEditPage' import { InventoryEditPage } from '@/pages/InventoryEditPage'
import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { DemandsPage } from '@/pages/DemandsPage'
import { DemandEditPage } from '@/pages/DemandEditPage'
import { SalesReportPage } from '@/pages/SalesReportPage' import { SalesReportPage } from '@/pages/SalesReportPage'
import { StockReportPage } from '@/pages/StockReportPage' import { StockReportPage } from '@/pages/StockReportPage'
import { ProfitReportPage } from '@/pages/ProfitReportPage' import { ProfitReportPage } from '@/pages/ProfitReportPage'
@ -136,6 +138,9 @@ export default function App() {
<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 />} />
<Route path="/sales/demands" element={<DemandsPage />} />
<Route path="/sales/demands/new" element={<DemandEditPage />} />
<Route path="/sales/demands/:id" element={<DemandEditPage />} />
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} /> <Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />

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, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -105,6 +105,7 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isCashier) { if (isAdmin || isCashier) {
sections.push({ group: 'Продажи', items: [ sections.push({ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' }, { to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'Оптовые отгрузки' }] : []),
]}) ]})
} }

View file

@ -137,6 +137,47 @@ export interface EnterDto {
lines: EnterLineDto[]; lines: EnterLineDto[];
} }
export const DemandStatus = { Draft: 0, Posted: 1 } as const
export type DemandStatus = (typeof DemandStatus)[keyof typeof DemandStatus]
export const DemandPayment = { Cash: 0, Card: 1, BankTransfer: 2, Credit: 3, Mixed: 99 } as const
export type DemandPayment = (typeof DemandPayment)[keyof typeof DemandPayment]
export const demandPaymentLabel: Record<DemandPayment, string> = {
[DemandPayment.Cash]: 'Наличные',
[DemandPayment.Card]: 'Карта',
[DemandPayment.BankTransfer]: 'Безнал',
[DemandPayment.Credit]: 'В кредит',
[DemandPayment.Mixed]: 'Смешанная',
}
export interface DemandListRow {
id: string; number: string; date: string; status: DemandStatus;
customerId: string; customerName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; paidAmount: number;
payment: DemandPayment; lineCount: number; postedAt: string | null;
}
export interface DemandLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; discount: number; lineTotal: number;
vatPercent: number; sortOrder: number;
stockAtStore: number | null;
}
export interface DemandDto {
id: string; number: string; date: string; status: DemandStatus;
customerId: string; customerName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
payment: DemandPayment;
subtotal: number; discountTotal: number; total: number; paidAmount: number;
notes: string | null; postedAt: string | null;
lines: DemandLineDto[];
}
export const LossStatus = { Draft: 0, Posted: 1 } as const export const LossStatus = { Draft: 0, Posted: 1 } as const
export type LossStatus = (typeof LossStatus)[keyof typeof LossStatus] export type LossStatus = (typeof LossStatus)[keyof typeof LossStatus]

View file

@ -0,0 +1,403 @@
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, AsyncSelect, 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 {
DemandStatus, DemandPayment, demandPaymentLabel,
type DemandDto, type Product,
} from '@/lib/types'
interface LineRow {
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
discount: number
vatPercent: number
stockAtStore: number | null
}
interface Form {
date: string
customerId: string
storeId: string
currencyId: string
payment: DemandPayment
paidAmount: number
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(), customerId: '', storeId: '', currencyId: '',
payment: DemandPayment.BankTransfer, paidAmount: 0,
notes: '', lines: [],
}
export function DemandEditPage() {
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/sales/demands', id],
queryFn: async () => (await api.get<DemandDto>(`/api/sales/demands/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
customerId: s.customerId,
storeId: s.storeId,
currencyId: s.currencyId,
payment: s.payment,
paidAmount: s.paidAmount,
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitPrice: l.unitPrice,
discount: l.discount,
vatPercent: l.vatPercent,
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 === DemandStatus.Draft
const isPosted = existing.data?.status === DemandStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
customerId: form.customerId,
storeId: form.storeId,
currencyId: form.currencyId,
payment: form.payment,
paidAmount: form.paidAmount,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice,
discount: l.discount, vatPercent: l.vatPercent,
})),
}
if (isNew) return (await api.post<DemandDto>('/api/sales/demands', payload)).data
await api.put(`/api/sales/demands/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/sales/demands/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
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/sales/demands/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) },
onSuccess: () => navigate('/sales/demands'),
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,
unitPrice: p.prices?.[0]?.amount ?? 0,
discount: 0,
vatPercent: p.vat,
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.customerId && !!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="/sales/demands" 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="Контрагент *">
<AsyncSelect
url="/api/catalog/counterparties"
value={form.customerId}
disabled={isPosted}
onChange={(v) => setForm({ ...form, customerId: v })}
placeholder="Выберите контрагента…"
/>
</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.payment)} disabled={isPosted}
onChange={(e) => setForm({ ...form, payment: Number(e.target.value) as DemandPayment })}>
{Object.entries(demandPaymentLabel).map(([v, lbl]) => (
<option key={v} value={v}>{lbl}</option>
))}
</Select>
</Field>
<Field label="Оплачено">
<MoneyInput value={form.paidAmount} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => setForm({ ...form, paidAmount: v ?? 0 })} />
</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 })} />
</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-[110px] text-right">Кол-во</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">Скидка</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[80px] 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.unitPrice} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => updateLine(i, { unitPrice: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right">
<MoneyInput value={l.discount} disabled={isPosted}
allowFractional={fractional}
onChange={(v) => updateLine(i, { discount: v ?? 0 })} />
</td>
<td className="py-2 px-3 text-right">
<NumberInput value={l.vatPercent} disabled={isPosted}
onChange={(v) => updateLine(i, { vatPercent: 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={7}>Итого</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,66 @@
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 DemandListRow, DemandStatus, demandPaymentLabel } from '@/lib/types'
const URL = '/api/sales/demands'
export function DemandsPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<DemandListRow>(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="/sales/demands/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(`/sales/demands/${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 === DemandStatus.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: 'customer', cell: (r) => r.customerName },
{ header: 'Склад', width: '160px', cell: (r) => r.storeName },
{ header: 'Оплата', width: '110px', cell: (r) => demandPaymentLabel[r.payment] ?? r.payment },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '140px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
{ header: 'Оплачено', width: '140px', className: 'text-right font-mono text-slate-500', cell: (r) => r.paidAmount.toLocaleString('ru', moneyFmt) },
]}
empty="Отгрузок пока нет. Создай первую — товар спишется со склада после проведения."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,110 @@
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 DemandPostUnpostTests
{
private readonly ApiFactory _factory;
public DemandPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Post_decrements_stock_unpost_restores()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dem-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var customerId = await api.CreateCounterpartyAsync($"Cust-{Guid.NewGuid():N}");
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 200m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 20m, unitCost = 100m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var demand = await api.Http.PostAsJsonAsync("/api/sales/demands", new
{
date = DateTime.UtcNow, customerId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
payment = 2, // BankTransfer
paidAmount = 0m, notes = "test demand",
lines = new[] { new { productId = p1, quantity = 5m, unitPrice = 180m, discount = 0m, vatPercent = 12m } },
});
demand.EnsureSuccessStatusCode();
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(15m);
using var unpost = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m);
}
[Fact]
public async Task Cannot_post_when_stock_insufficient()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dem-short-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var customerId = await api.CreateCounterpartyAsync($"Cust-{Guid.NewGuid():N}");
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 2m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var demand = await api.Http.PostAsJsonAsync("/api/sales/demands", new
{
date = DateTime.UtcNow, customerId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
payment = 2, paidAmount = 0m, notes = "over",
lines = new[] { new { productId = p1, quantity = 5m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
});
demand.EnsureSuccessStatusCode();
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/post", new { });
((int)post.StatusCode).Should().Be(409);
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(2m);
}
[Fact]
public async Task Tenant_isolation_demand()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"dem-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"dem-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var customerA = await a.CreateCounterpartyAsync($"C-{Guid.NewGuid():N}");
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
var demand = await a.Http.PostAsJsonAsync("/api/sales/demands", new
{
date = DateTime.UtcNow, customerId = customerA, storeId = refsA.StoreId, currencyId = refsA.CurrencyId,
payment = 2, paidAmount = 0m, notes = "iso",
lines = new[] { new { productId = pA, quantity = 1m, unitPrice = 10m, discount = 0m, vatPercent = 12m } },
});
demand.EnsureSuccessStatusCode();
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/sales/demands?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == did);
using var direct = await b.Http.GetAsync($"/api/sales/demands/{did}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}