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:
parent
602c0579ec
commit
47a019dc6d
402
src/food-market.api/Controllers/Sales/DemandsController.cs
Normal file
402
src/food-market.api/Controllers/Sales/DemandsController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/food-market.domain/Sales/Demand.cs
Normal file
77
src/food-market.domain/Sales/Demand.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>} />
|
||||||
|
|
|
||||||
|
|
@ -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: 'Оптовые отгрузки' }] : []),
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
403
src/food-market.web/src/pages/DemandEditPage.tsx
Normal file
403
src/food-market.web/src/pages/DemandEditPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/food-market.web/src/pages/DemandsPage.tsx
Normal file
66
src/food-market.web/src/pages/DemandsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
tests/food-market.IntegrationTests/DemandPostUnpostTests.cs
Normal file
110
tests/food-market.IntegrationTests/DemandPostUnpostTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue