Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 28s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 39s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
UI: - Pagination: ввод страницы заменён на select (option 1..totalPages), по выбору сразу setPage. Стрелки ← → остаются. - Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и NumberInput (decimal без валюты). Оба фильтруют ввод регулярно (только цифры + точка/запятая→точка), при focus — выделяют значение. - ProductEditPage: purchasePrice / vat / minStock / maxStock / amount в ценах продаж переведены на новые компоненты; символ валюты — из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг. - SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput с символом из form.currencyId. - CountriesPage: vatRate — NumberInput. API: - ProductInput / ProductPriceInput / SupplyLineInput / RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)] на денежные/количественные поля и [Range(0,100)] на проценты. ASP.NET автоматически валидирует и возвращает 400 при выходе. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
13 KiB
C#
312 lines
13 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using foodmarket.Application.Common;
|
|
using foodmarket.Application.Inventory;
|
|
using foodmarket.Domain.Inventory;
|
|
using foodmarket.Domain.Purchases;
|
|
using foodmarket.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace foodmarket.Api.Controllers.Purchases;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/purchases/supplies")]
|
|
public class SuppliesController : ControllerBase
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IStockService _stock;
|
|
|
|
public SuppliesController(AppDbContext db, IStockService stock)
|
|
{
|
|
_db = db;
|
|
_stock = stock;
|
|
}
|
|
|
|
public record SupplyListRow(
|
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
|
Guid SupplierId, string SupplierName,
|
|
Guid StoreId, string StoreName,
|
|
Guid CurrencyId, string CurrencyCode,
|
|
decimal Total, int LineCount,
|
|
DateTime? PostedAt);
|
|
|
|
public record SupplyLineDto(
|
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
|
string? UnitSymbol,
|
|
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder);
|
|
|
|
public record SupplyDto(
|
|
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
|
Guid SupplierId, string SupplierName,
|
|
Guid StoreId, string StoreName,
|
|
Guid CurrencyId, string CurrencyCode,
|
|
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
|
string? Notes,
|
|
decimal Total, DateTime? PostedAt,
|
|
IReadOnlyList<SupplyLineDto> Lines);
|
|
|
|
public record SupplyLineInput(
|
|
Guid ProductId,
|
|
[Range(0, 1e10)] decimal Quantity,
|
|
[Range(0, 1e10)] decimal UnitPrice);
|
|
public record SupplyInput(
|
|
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
|
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
|
string? Notes,
|
|
IReadOnlyList<SupplyLineInput> Lines);
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
|
[FromQuery] PagedRequest req,
|
|
[FromQuery] SupplyStatus? status,
|
|
[FromQuery] Guid? storeId,
|
|
[FromQuery] Guid? supplierId,
|
|
CancellationToken ct)
|
|
{
|
|
var q = from s in _db.Supplies.AsNoTracking()
|
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
|
join st in _db.Stores on s.StoreId equals st.Id
|
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
|
select new { s, cp, st, cu };
|
|
|
|
if (status is not null) q = q.Where(x => x.s.Status == status);
|
|
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
|
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
|
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
|
{
|
|
var s = req.Search.Trim().ToLower();
|
|
q = q.Where(x => x.s.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.s.Number),
|
|
("number", true) => q.OrderByDescending(x => x.s.Number),
|
|
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
|
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
|
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
|
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
|
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
|
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
|
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
|
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
|
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
|
|
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
|
|
};
|
|
var items = await q
|
|
.Skip(req.Skip).Take(req.Take)
|
|
.Select(x => new SupplyListRow(
|
|
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
|
x.cp.Id, x.cp.Name,
|
|
x.st.Id, x.st.Name,
|
|
x.cu.Id, x.cu.Code,
|
|
x.s.Total,
|
|
x.s.Lines.Count,
|
|
x.s.PostedAt))
|
|
.ToListAsync(ct);
|
|
|
|
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
|
{
|
|
var dto = await GetInternal(id, ct);
|
|
return dto is null ? NotFound() : Ok(dto);
|
|
}
|
|
|
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
|
{
|
|
var number = await GenerateNumberAsync(input.Date, ct);
|
|
var supply = new Supply
|
|
{
|
|
Number = number,
|
|
Date = input.Date,
|
|
Status = SupplyStatus.Draft,
|
|
SupplierId = input.SupplierId,
|
|
StoreId = input.StoreId,
|
|
CurrencyId = input.CurrencyId,
|
|
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
|
|
SupplierInvoiceDate = input.SupplierInvoiceDate,
|
|
Notes = input.Notes,
|
|
};
|
|
|
|
var order = 0;
|
|
foreach (var l in input.Lines)
|
|
{
|
|
supply.Lines.Add(new SupplyLine
|
|
{
|
|
ProductId = l.ProductId,
|
|
Quantity = l.Quantity,
|
|
UnitPrice = l.UnitPrice,
|
|
LineTotal = l.Quantity * l.UnitPrice,
|
|
SortOrder = order++,
|
|
});
|
|
}
|
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
|
|
|
_db.Supplies.Add(supply);
|
|
await _db.SaveChangesAsync(ct);
|
|
var dto = await GetInternal(supply.Id, ct);
|
|
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
|
}
|
|
|
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
|
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
|
{
|
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
|
if (supply is null) return NotFound();
|
|
if (supply.Status != SupplyStatus.Draft)
|
|
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
|
|
|
supply.Date = input.Date;
|
|
supply.SupplierId = input.SupplierId;
|
|
supply.StoreId = input.StoreId;
|
|
supply.CurrencyId = input.CurrencyId;
|
|
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
|
|
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
|
|
supply.Notes = input.Notes;
|
|
|
|
// Replace lines wholesale (simple, idempotent).
|
|
_db.SupplyLines.RemoveRange(supply.Lines);
|
|
supply.Lines.Clear();
|
|
var order = 0;
|
|
foreach (var l in input.Lines)
|
|
{
|
|
supply.Lines.Add(new SupplyLine
|
|
{
|
|
SupplyId = supply.Id,
|
|
ProductId = l.ProductId,
|
|
Quantity = l.Quantity,
|
|
UnitPrice = l.UnitPrice,
|
|
LineTotal = l.Quantity * l.UnitPrice,
|
|
SortOrder = order++,
|
|
});
|
|
}
|
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|
{
|
|
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
|
if (supply is null) return NotFound();
|
|
if (supply.Status != SupplyStatus.Draft)
|
|
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
|
|
_db.Supplies.Remove(supply);
|
|
await _db.SaveChangesAsync(ct);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
|
{
|
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
|
if (supply is null) return NotFound();
|
|
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
|
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
|
|
|
foreach (var line in supply.Lines)
|
|
{
|
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
|
ProductId: line.ProductId,
|
|
StoreId: supply.StoreId,
|
|
Quantity: line.Quantity,
|
|
Type: MovementType.Supply,
|
|
DocumentType: "supply",
|
|
DocumentId: supply.Id,
|
|
DocumentNumber: supply.Number,
|
|
UnitCost: line.UnitPrice,
|
|
OccurredAt: supply.Date), ct);
|
|
}
|
|
|
|
supply.Status = SupplyStatus.Posted;
|
|
supply.PostedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
|
{
|
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
|
if (supply is null) return NotFound();
|
|
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
|
|
|
// Reverse: negative movements with same document reference
|
|
foreach (var line in supply.Lines)
|
|
{
|
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
|
ProductId: line.ProductId,
|
|
StoreId: supply.StoreId,
|
|
Quantity: -line.Quantity,
|
|
Type: MovementType.Supply,
|
|
DocumentType: "supply-reversal",
|
|
DocumentId: supply.Id,
|
|
DocumentNumber: supply.Number,
|
|
UnitCost: line.UnitPrice,
|
|
OccurredAt: DateTime.UtcNow,
|
|
Notes: $"Отмена проведения документа {supply.Number}"), ct);
|
|
}
|
|
|
|
supply.Status = SupplyStatus.Draft;
|
|
supply.PostedAt = null;
|
|
await _db.SaveChangesAsync(ct);
|
|
return NoContent();
|
|
}
|
|
|
|
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
|
{
|
|
var year = date.Year;
|
|
var prefix = $"П-{year}-";
|
|
var lastNumber = await _db.Supplies
|
|
.Where(s => s.Number.StartsWith(prefix))
|
|
.OrderByDescending(s => s.Number)
|
|
.Select(s => s.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<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
|
{
|
|
var row = await (from s in _db.Supplies.AsNoTracking()
|
|
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
|
join st in _db.Stores on s.StoreId equals st.Id
|
|
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
|
where s.Id == id
|
|
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
|
|
if (row is null) return null;
|
|
|
|
var lines = await (from l in _db.SupplyLines.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.SupplyId == id
|
|
orderby l.SortOrder
|
|
select new SupplyLineDto(
|
|
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
|
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
|
|
.ToListAsync(ct);
|
|
|
|
return new SupplyDto(
|
|
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
|
row.cp.Id, row.cp.Name,
|
|
row.st.Id, row.st.Name,
|
|
row.cu.Id, row.cu.Code,
|
|
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
|
|
row.s.Notes,
|
|
row.s.Total, row.s.PostedAt,
|
|
lines);
|
|
}
|
|
}
|