From 6b86106937c7712467d757ed38611f6d9de701a9 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:22:56 +0500 Subject: [PATCH] phase1b: catalog CRUD API (countries, currencies, vat, units, stores, retail points, product groups, counterparties, products) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application layer: - PagedRequest/PagedResult with sane defaults (pageSize 50, max 500) - CatalogDtos: read DTOs with joined names + input DTOs for upsert - Product input supports nested Prices[] and Barcodes[] for atomic save API controllers (api/catalog/…): - countries, currencies (global, write requires SuperAdmin) - vat-rates, units-of-measure, price-types, stores (write: Admin/Manager) - retail-points (references Store, Admin/Manager write) - product-groups: hierarchy with auto-computed Path, delete guarded against children/products - counterparties: filter by kind (Supplier/Customer/Both), full join with Country - products: includes joined lookups, filter by group/isService/isWeighed/isActive, search by name/article/barcode, write replaces Prices+Barcodes atomically Role semantics: - SuperAdmin: mutates global references only - Admin: mutates/deletes tenant references - Manager: mutates tenant references (no delete on some) - Storekeeper: can manage counterparties and products (but not delete) All endpoints guarded by [Authorize]. Multi-tenant isolation via EF query filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Catalog/CounterpartiesController.cs | 125 ++++++++++++++ .../Catalog/CountriesController.cs | 75 ++++++++ .../Catalog/CurrenciesController.cs | 75 ++++++++ .../Catalog/PriceTypesController.cs | 90 ++++++++++ .../Catalog/ProductGroupsController.cs | 104 +++++++++++ .../Controllers/Catalog/ProductsController.cs | 162 ++++++++++++++++++ .../Catalog/RetailPointsController.cs | 93 ++++++++++ .../Controllers/Catalog/StoresController.cs | 93 ++++++++++ .../Catalog/UnitsOfMeasureController.cs | 98 +++++++++++ .../Controllers/Catalog/VatRatesController.cs | 101 +++++++++++ .../Catalog/CatalogDtos.cs | 84 +++++++++ .../Common/PagedRequest.cs | 23 +++ 12 files changed, 1123 insertions(+) create mode 100644 src/food-market.api/Controllers/Catalog/CounterpartiesController.cs create mode 100644 src/food-market.api/Controllers/Catalog/CountriesController.cs create mode 100644 src/food-market.api/Controllers/Catalog/CurrenciesController.cs create mode 100644 src/food-market.api/Controllers/Catalog/PriceTypesController.cs create mode 100644 src/food-market.api/Controllers/Catalog/ProductGroupsController.cs create mode 100644 src/food-market.api/Controllers/Catalog/ProductsController.cs create mode 100644 src/food-market.api/Controllers/Catalog/RetailPointsController.cs create mode 100644 src/food-market.api/Controllers/Catalog/StoresController.cs create mode 100644 src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs create mode 100644 src/food-market.api/Controllers/Catalog/VatRatesController.cs create mode 100644 src/food-market.application/Catalog/CatalogDtos.cs create mode 100644 src/food-market.application/Common/PagedRequest.cs diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs new file mode 100644 index 0000000..f3040a2 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -0,0 +1,125 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/counterparties")] +public class CounterpartiesController : ControllerBase +{ + private readonly AppDbContext _db; + + public CounterpartiesController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] CounterpartyKind? kind, + CancellationToken ct) + { + var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable(); + if (kind is not null) + { + q = q.Where(c => c.Kind == kind || c.Kind == CounterpartyKind.Both); + } + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(c => + c.Name.ToLower().Contains(s) || + (c.LegalName != null && c.LegalName.ToLower().Contains(s)) || + (c.Bin != null && c.Bin.Contains(s)) || + (c.Iin != null && c.Iin.Contains(s)) || + (c.Phone != null && c.Phone.Contains(s))); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(c => c.Name) + .Skip(req.Skip).Take(req.Take) + .Select(c => new CounterpartyDto( + c.Id, c.Name, c.LegalName, c.Kind, c.Type, + c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null, + c.Address, c.Phone, c.Email, + c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return c is null ? NotFound() : new CounterpartyDto( + c.Id, c.Name, c.LegalName, c.Kind, c.Type, + c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, + c.Address, c.Phone, c.Email, + c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task> Create([FromBody] CounterpartyInput input, CancellationToken ct) + { + var e = Apply(new Counterparty(), input); + _db.Counterparties.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) + { + var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + Apply(e, input); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.Counterparties.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private static Counterparty Apply(Counterparty e, CounterpartyInput i) + { + e.Name = i.Name; + e.LegalName = i.LegalName; + e.Kind = i.Kind; + e.Type = i.Type; + e.Bin = i.Bin; + e.Iin = i.Iin; + e.TaxNumber = i.TaxNumber; + e.CountryId = i.CountryId; + e.Address = i.Address; + e.Phone = i.Phone; + e.Email = i.Email; + e.BankName = i.BankName; + e.BankAccount = i.BankAccount; + e.Bik = i.Bik; + e.ContactPerson = i.ContactPerson; + e.Notes = i.Notes; + e.IsActive = i.IsActive; + return e; + } + + private async Task ProjectAsync(Guid id, CancellationToken ct) + { + var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstAsync(x => x.Id == id, ct); + return new CounterpartyDto( + c.Id, c.Name, c.LegalName, c.Kind, c.Type, + c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, + c.Address, c.Phone, c.Email, + c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); + } +} diff --git a/src/food-market.api/Controllers/Catalog/CountriesController.cs b/src/food-market.api/Controllers/Catalog/CountriesController.cs new file mode 100644 index 0000000..28cd119 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/CountriesController.cs @@ -0,0 +1,75 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/countries")] +public class CountriesController : ControllerBase +{ + private readonly AppDbContext _db; + + public CountriesController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.Countries.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(c => c.SortOrder).ThenBy(c => c.Name) + .Skip(req.Skip).Take(req.Take) + .Select(c => new CountryDto(c.Id, c.Code, c.Name, c.SortOrder)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var c = await _db.Countries.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return c is null ? NotFound() : new CountryDto(c.Id, c.Code, c.Name, c.SortOrder); + } + + [HttpPost, Authorize(Roles = "SuperAdmin")] + public async Task> Create([FromBody] CountryInput input, CancellationToken ct) + { + var e = new Country { Code = input.Code.Trim().ToUpper(), Name = input.Name, SortOrder = input.SortOrder }; + _db.Countries.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, new CountryDto(e.Id, e.Code, e.Name, e.SortOrder)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] + public async Task Update(Guid id, [FromBody] CountryInput input, CancellationToken ct) + { + var e = await _db.Countries.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + e.Code = input.Code.Trim().ToUpper(); + e.Name = input.Name; + e.SortOrder = input.SortOrder; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.Countries.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.Countries.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/CurrenciesController.cs b/src/food-market.api/Controllers/Catalog/CurrenciesController.cs new file mode 100644 index 0000000..93b37db --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/CurrenciesController.cs @@ -0,0 +1,75 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/currencies")] +public class CurrenciesController : ControllerBase +{ + private readonly AppDbContext _db; + + public CurrenciesController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.Currencies.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(c => c.Code) + .Skip(req.Skip).Take(req.Take) + .Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive); + } + + [HttpPost, Authorize(Roles = "SuperAdmin")] + public async Task> Create([FromBody] CurrencyInput input, CancellationToken ct) + { + var e = new Currency + { + Code = input.Code.Trim().ToUpper(), + Name = input.Name, + Symbol = input.Symbol, + MinorUnit = input.MinorUnit, + IsActive = input.IsActive, + }; + _db.Currencies.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol, e.MinorUnit, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")] + public async Task Update(Guid id, [FromBody] CurrencyInput input, CancellationToken ct) + { + var e = await _db.Currencies.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + e.Code = input.Code.Trim().ToUpper(); + e.Name = input.Name; + e.Symbol = input.Symbol; + e.MinorUnit = input.MinorUnit; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs new file mode 100644 index 0000000..be67bba --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs @@ -0,0 +1,90 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/price-types")] +public class PriceTypesController : ControllerBase +{ + private readonly AppDbContext _db; + + public PriceTypesController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.PriceTypes.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(p => p.Name.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name) + .Skip(req.Skip).Take(req.Take) + .Select(p => new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] PriceTypeInput input, CancellationToken ct) + { + if (input.IsDefault) + { + await _db.PriceTypes.Where(p => p.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct); + } + var e = new PriceType + { + Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail, + SortOrder = input.SortOrder, IsActive = input.IsActive, + }; + _db.PriceTypes.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new PriceTypeDto(e.Id, e.Name, e.IsDefault, e.IsRetail, e.SortOrder, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct) + { + var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + if (input.IsDefault && !e.IsDefault) + { + await _db.PriceTypes.Where(p => p.IsDefault && p.Id != id).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct); + } + e.Name = input.Name; + e.IsDefault = input.IsDefault; + e.IsRetail = input.IsRetail; + e.SortOrder = input.SortOrder; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.PriceTypes.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs new file mode 100644 index 0000000..6a0be88 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -0,0 +1,104 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/product-groups")] +public class ProductGroupsController : ControllerBase +{ + private readonly AppDbContext _db; + + public ProductGroupsController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] Guid? parentId, + CancellationToken ct) + { + var q = _db.ProductGroups.AsNoTracking().AsQueryable(); + if (parentId is not null) + { + q = parentId == Guid.Empty ? q.Where(g => g.ParentId == null) : q.Where(g => g.ParentId == parentId); + } + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name) + .Skip(req.Skip).Take(req.Take) + .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] ProductGroupInput input, CancellationToken ct) + { + var path = await BuildPathAsync(input.ParentId, input.Name, ct); + var e = new ProductGroup + { + Name = input.Name, ParentId = input.ParentId, Path = path, + SortOrder = input.SortOrder, IsActive = input.IsActive, + }; + _db.ProductGroups.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) + { + var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" }); + + e.Name = input.Name; + e.ParentId = input.ParentId; + e.Path = await BuildPathAsync(input.ParentId, input.Name, ct); + e.SortOrder = input.SortOrder; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct); + if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" }); + var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct); + if (hasProducts) return BadRequest(new { error = "Нельзя удалить группу с товарами" }); + + var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.ProductGroups.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private async Task BuildPathAsync(Guid? parentId, string name, CancellationToken ct) + { + if (parentId is null) return name; + var parent = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == parentId, ct); + return parent is null ? name : $"{parent.Path}/{name}"; + } +} diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs new file mode 100644 index 0000000..d6b2d73 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -0,0 +1,162 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/products")] +public class ProductsController : ControllerBase +{ + private readonly AppDbContext _db; + + public ProductsController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] Guid? groupId, + [FromQuery] bool? isService, + [FromQuery] bool? isWeighed, + [FromQuery] bool? isActive, + CancellationToken ct) + { + var q = QueryIncludes().AsNoTracking(); + if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId); + if (isService is not null) q = q.Where(p => p.IsService == isService); + if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed); + if (isActive is not null) q = q.Where(p => p.IsActive == isActive); + + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(p => + p.Name.ToLower().Contains(s) || + (p.Article != null && p.Article.ToLower().Contains(s)) || + p.Barcodes.Any(b => b.Code.Contains(s))); + } + + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(p => p.Name) + .Skip(req.Skip).Take(req.Take) + .Select(Projection) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstOrDefaultAsync(ct); + return p is null ? NotFound() : p; + } + + [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task> Create([FromBody] ProductInput input, CancellationToken ct) + { + var e = new Product(); + Apply(e, input); + + foreach (var b in input.Barcodes ?? []) + e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); + foreach (var pr in input.Prices ?? []) + e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); + + _db.Products.Add(e); + await _db.SaveChangesAsync(ct); + var dto = await GetInternalAsync(e.Id, ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) + { + var e = await _db.Products + .Include(p => p.Barcodes) + .Include(p => p.Prices) + .FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + + Apply(e, input); + + _db.ProductBarcodes.RemoveRange(e.Barcodes); + e.Barcodes.Clear(); + foreach (var b in input.Barcodes ?? []) + e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); + + _db.ProductPrices.RemoveRange(e.Prices); + e.Prices.Clear(); + foreach (var pr in input.Prices ?? []) + e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId }); + + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.Products.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private IQueryable QueryIncludes() => _db.Products + .Include(p => p.UnitOfMeasure) + .Include(p => p.VatRate) + .Include(p => p.ProductGroup) + .Include(p => p.DefaultSupplier) + .Include(p => p.CountryOfOrigin) + .Include(p => p.PurchaseCurrency) + .Include(p => p.Barcodes) + .Include(p => p.Prices).ThenInclude(pr => pr.PriceType) + .Include(p => p.Prices).ThenInclude(pr => pr.Currency); + + private async Task GetInternalAsync(Guid id, CancellationToken ct) => + await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstAsync(ct); + + private static readonly System.Linq.Expressions.Expression> Projection = p => + new ProductDto( + p.Id, p.Name, p.Article, p.Description, + p.UnitOfMeasureId, p.UnitOfMeasure!.Symbol, + p.VatRateId, p.VatRate!.Percent, + p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null, + p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null, + p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null, + p.IsService, p.IsWeighed, p.IsAlcohol, p.IsMarked, + p.MinStock, p.MaxStock, + p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, + p.ImageUrl, p.IsActive, + p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), + p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); + + private static void Apply(Product e, ProductInput i) + { + e.Name = i.Name; + e.Article = i.Article; + e.Description = i.Description; + e.UnitOfMeasureId = i.UnitOfMeasureId; + e.VatRateId = i.VatRateId; + e.ProductGroupId = i.ProductGroupId; + e.DefaultSupplierId = i.DefaultSupplierId; + e.CountryOfOriginId = i.CountryOfOriginId; + e.IsService = i.IsService; + e.IsWeighed = i.IsWeighed; + e.IsAlcohol = i.IsAlcohol; + e.IsMarked = i.IsMarked; + e.MinStock = i.MinStock; + e.MaxStock = i.MaxStock; + e.PurchasePrice = i.PurchasePrice; + e.PurchaseCurrencyId = i.PurchaseCurrencyId; + e.ImageUrl = i.ImageUrl; + e.IsActive = i.IsActive; + } +} diff --git a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs new file mode 100644 index 0000000..8e0c822 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs @@ -0,0 +1,93 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/retail-points")] +public class RetailPointsController : ControllerBase +{ + private readonly AppDbContext _db; + + public RetailPointsController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.RetailPoints.Include(r => r.Store).AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s))); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderBy(r => r.Name) + .Skip(req.Skip).Take(req.Take) + .Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, + r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var r = await _db.RetailPoints.Include(x => x.Store).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return r is null ? NotFound() : new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, + r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] RetailPointInput input, CancellationToken ct) + { + var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct); + if (store is null) return BadRequest(new { error = "Invalid StoreId" }); + + var e = new RetailPoint + { + Name = input.Name, Code = input.Code, StoreId = input.StoreId, + Address = input.Address, Phone = input.Phone, + FiscalSerial = input.FiscalSerial, FiscalRegNumber = input.FiscalRegNumber, + IsActive = input.IsActive, + }; + _db.RetailPoints.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new RetailPointDto(e.Id, e.Name, e.Code, e.StoreId, store.Name, + e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct) + { + var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + e.Name = input.Name; + e.Code = input.Code; + e.StoreId = input.StoreId; + e.Address = input.Address; + e.Phone = input.Phone; + e.FiscalSerial = input.FiscalSerial; + e.FiscalRegNumber = input.FiscalRegNumber; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.RetailPoints.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/StoresController.cs b/src/food-market.api/Controllers/Catalog/StoresController.cs new file mode 100644 index 0000000..8e638f6 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/StoresController.cs @@ -0,0 +1,93 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/stores")] +public class StoresController : ControllerBase +{ + private readonly AppDbContext _db; + + public StoresController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.Stores.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s))); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(x => x.IsMain).ThenBy(x => x.Name) + .Skip(req.Skip).Take(req.Take) + .Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var x = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct); + return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Kind, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] StoreInput input, CancellationToken ct) + { + if (input.IsMain) + { + await _db.Stores.Where(s => s.IsMain).ExecuteUpdateAsync(s => s.SetProperty(x => x.IsMain, false), ct); + } + var e = new Store + { + Name = input.Name, Code = input.Code, Kind = input.Kind, Address = input.Address, + Phone = input.Phone, ManagerName = input.ManagerName, IsMain = input.IsMain, IsActive = input.IsActive, + }; + _db.Stores.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new StoreDto(e.Id, e.Name, e.Code, e.Kind, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] StoreInput input, CancellationToken ct) + { + var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + if (input.IsMain && !e.IsMain) + { + await _db.Stores.Where(s => s.IsMain && s.Id != id).ExecuteUpdateAsync(s => s.SetProperty(x => x.IsMain, false), ct); + } + e.Name = input.Name; + e.Code = input.Code; + e.Kind = input.Kind; + e.Address = input.Address; + e.Phone = input.Phone; + e.ManagerName = input.ManagerName; + e.IsMain = input.IsMain; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.Stores.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs new file mode 100644 index 0000000..63881ab --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs @@ -0,0 +1,98 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/units-of-measure")] +public class UnitsOfMeasureController : ControllerBase +{ + private readonly AppDbContext _db; + + public UnitsOfMeasureController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.UnitsOfMeasure.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(u => u.Name.ToLower().Contains(s) || u.Symbol.ToLower().Contains(s) || u.Code.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(u => u.IsBase).ThenBy(u => u.Name) + .Skip(req.Skip).Take(req.Take) + .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Symbol, u.Name, u.DecimalPlaces, u.IsBase, u.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] UnitOfMeasureInput input, CancellationToken ct) + { + if (input.IsBase) + { + await _db.UnitsOfMeasure.Where(u => u.IsBase).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct); + } + + var e = new UnitOfMeasure + { + Code = input.Code, + Symbol = input.Symbol, + Name = input.Name, + DecimalPlaces = input.DecimalPlaces, + IsBase = input.IsBase, + IsActive = input.IsActive, + }; + _db.UnitsOfMeasure.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new UnitOfMeasureDto(e.Id, e.Code, e.Symbol, e.Name, e.DecimalPlaces, e.IsBase, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct) + { + var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + + if (input.IsBase && !e.IsBase) + { + await _db.UnitsOfMeasure.Where(u => u.IsBase && u.Id != id).ExecuteUpdateAsync(s => s.SetProperty(u => u.IsBase, false), ct); + } + + e.Code = input.Code; + e.Symbol = input.Symbol; + e.Name = input.Name; + e.DecimalPlaces = input.DecimalPlaces; + e.IsBase = input.IsBase; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.UnitsOfMeasure.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Catalog/VatRatesController.cs b/src/food-market.api/Controllers/Catalog/VatRatesController.cs new file mode 100644 index 0000000..d72b01b --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/VatRatesController.cs @@ -0,0 +1,101 @@ +using foodmarket.Application.Catalog; +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +[ApiController] +[Authorize] +[Route("api/catalog/vat-rates")] +public class VatRatesController : ControllerBase +{ + private readonly AppDbContext _db; + + public VatRatesController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task>> List([FromQuery] PagedRequest req, CancellationToken ct) + { + var q = _db.VatRates.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(v => v.Name.ToLower().Contains(s)); + } + var total = await q.CountAsync(ct); + var items = await q + .OrderByDescending(v => v.IsDefault).ThenBy(v => v.Percent) + .Skip(req.Skip).Take(req.Take) + .Select(v => new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive)) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var v = await _db.VatRates.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return v is null ? NotFound() : new VatRateDto(v.Id, v.Name, v.Percent, v.IsIncludedInPrice, v.IsDefault, v.IsActive); + } + + [HttpPost, Authorize(Roles = "Admin,Manager")] + public async Task> Create([FromBody] VatRateInput input, CancellationToken ct) + { + if (input.IsDefault) + { + await ResetDefaultsAsync(ct); + } + + var e = new VatRate + { + Name = input.Name, + Percent = input.Percent, + IsIncludedInPrice = input.IsIncludedInPrice, + IsDefault = input.IsDefault, + IsActive = input.IsActive, + }; + _db.VatRates.Add(e); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = e.Id }, + new VatRateDto(e.Id, e.Name, e.Percent, e.IsIncludedInPrice, e.IsDefault, e.IsActive)); + } + + [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] + public async Task Update(Guid id, [FromBody] VatRateInput input, CancellationToken ct) + { + var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + + if (input.IsDefault && !e.IsDefault) + { + await ResetDefaultsAsync(ct); + } + + e.Name = input.Name; + e.Percent = input.Percent; + e.IsIncludedInPrice = input.IsIncludedInPrice; + e.IsDefault = input.IsDefault; + e.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.VatRates.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + _db.VatRates.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private async Task ResetDefaultsAsync(CancellationToken ct) + { + await _db.VatRates.Where(v => v.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(v => v.IsDefault, false), ct); + } +} diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs new file mode 100644 index 0000000..859cc9f --- /dev/null +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -0,0 +1,84 @@ +using foodmarket.Domain.Catalog; + +namespace foodmarket.Application.Catalog; + +public record CountryDto(Guid Id, string Code, string Name, int SortOrder); + +public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive); + +public record VatRateDto( + Guid Id, string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault, bool IsActive); + +public record UnitOfMeasureDto( + Guid Id, string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase, bool IsActive); + +public record PriceTypeDto( + Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive); + +public record StoreDto( + Guid Id, string Name, string? Code, StoreKind Kind, string? Address, string? Phone, + string? ManagerName, bool IsMain, bool IsActive); + +public record RetailPointDto( + Guid Id, string Name, string? Code, Guid StoreId, string? StoreName, + string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive); + +public record ProductGroupDto( + Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive); + +public record CounterpartyDto( + Guid Id, string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type, + string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName, + string? Address, string? Phone, string? Email, + string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive); + +public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary); + +public record ProductPriceDto(Guid Id, Guid PriceTypeId, string PriceTypeName, decimal Amount, Guid CurrencyId, string CurrencyCode); + +public record ProductDto( + Guid Id, string Name, string? Article, string? Description, + Guid UnitOfMeasureId, string UnitSymbol, + Guid VatRateId, decimal VatPercent, + Guid? ProductGroupId, string? ProductGroupName, + Guid? DefaultSupplierId, string? DefaultSupplierName, + Guid? CountryOfOriginId, string? CountryOfOriginName, + bool IsService, bool IsWeighed, bool IsAlcohol, bool IsMarked, + decimal? MinStock, decimal? MaxStock, + decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, + string? ImageUrl, bool IsActive, + IReadOnlyList Prices, + IReadOnlyList Barcodes); + +// Upsert payloads (input) +public record CountryInput(string Code, string Name, int SortOrder = 0); +public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true); +public record VatRateInput(string Name, decimal Percent, bool IsIncludedInPrice, bool IsDefault = false, bool IsActive = true); +public record UnitOfMeasureInput(string Code, string Symbol, string Name, int DecimalPlaces, bool IsBase = false, bool IsActive = true); +public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); +public record StoreInput( + string Name, string? Code, StoreKind Kind = StoreKind.Warehouse, + string? Address = null, string? Phone = null, string? ManagerName = null, + bool IsMain = false, bool IsActive = true); +public record RetailPointInput( + string Name, string? Code, Guid StoreId, + string? Address = null, string? Phone = null, + string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); +public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true); +public record CounterpartyInput( + string Name, string? LegalName, CounterpartyKind Kind, CounterpartyType Type, + string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, + string? Address, string? Phone, string? Email, + string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); +public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false); +public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); +public record ProductInput( + string Name, string? Article, string? Description, + Guid UnitOfMeasureId, Guid VatRateId, + Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, + bool IsService = false, bool IsWeighed = false, bool IsAlcohol = false, bool IsMarked = false, + decimal? MinStock = null, decimal? MaxStock = null, + decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, + string? ImageUrl = null, bool IsActive = true, + IReadOnlyList? Prices = null, + IReadOnlyList? Barcodes = null); diff --git a/src/food-market.application/Common/PagedRequest.cs b/src/food-market.application/Common/PagedRequest.cs new file mode 100644 index 0000000..c3e7764 --- /dev/null +++ b/src/food-market.application/Common/PagedRequest.cs @@ -0,0 +1,23 @@ +namespace foodmarket.Application.Common; + +public sealed class PagedRequest +{ + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 50; + public string? Search { get; init; } + public string? SortBy { get; init; } + public bool SortDesc { get; init; } + + public int Skip => Math.Max(0, (Page - 1) * PageSize); + public int Take => Math.Clamp(PageSize, 1, 500); +} + +public sealed class PagedResult +{ + public required IReadOnlyList Items { get; init; } + public required int Total { get; init; } + public required int Page { get; init; } + public required int PageSize { get; init; } + + public int TotalPages => PageSize == 0 ? 0 : (int)Math.Ceiling(Total / (double)PageSize); +}