feat: Implement Category, Item, UOM, Vendor, and Warehouse services with CRUD operations

- Added CategoryService for managing categories with listing, tree structure, and creation functionalities.
- Introduced ItemService for item management, including listing, detail retrieval, creation, updating, and status management.
- Created UomService for handling unit of measure operations, including listing and creation.
- Developed VendorService for vendor management, supporting listing, detail retrieval, creation, updating, and status management.
- Implemented WarehouseService for warehouse and bin management, including listing warehouses, creating warehouses, and managing bins within warehouses.
- Added interfaces for each service to define the contract for service implementations.
- Generated Entity Framework Core model snapshot for database migrations.
This commit is contained in:
2026-07-10 10:44:30 +05:30
parent ec94bac410
commit 057dd5aedc
54 changed files with 3437 additions and 18 deletions
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class CategoryService : ICategoryService
{
private readonly IRepository<Category> _categories;
private readonly IUnitOfWork _uow;
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
{
_categories = categories;
_uow = uow;
}
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _categories.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
{
var all = await _categories.Query().AsNoTracking()
.OrderBy(c => c.Name)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
var byParent = all.ToLookup(c => c.ParentId);
List<CategoryTreeDto> Build(long? parentId) =>
byParent[parentId]
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
.ToList();
return Build(null);
}
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{
if (request.ParentId is not null
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
await _categories.AddAsync(category, ct);
await _uow.SaveChangesAsync(ct);
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
}
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public interface ICategoryService
{
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,28 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Item master business logic (docs/11-BACKEND-PHASE1.md §2.12.2). Returns DTOs;
/// entities never cross this boundary (00-CORE §4).
/// </summary>
public interface IItemService
{
Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(long itemId, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>> UpdateAsync(long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default);
Task<ItemReorderSettingsDto> UpdateReorderAsync(long itemId, UpdateReorderRequest request, CancellationToken ct = default);
Task<ItemUomConversionsDto> UpdateUomConversionsAsync(long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService
{
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor master business logic (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public interface IVendorService
{
Task<PagedResponse<VendorDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<VendorDto>?> GetAsync(long vendorId, CancellationToken ct = default);
Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default);
Task<ETagged<VendorDto>> UpdateAsync(long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService
{
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(long warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
Task<IReadOnlyList<BinDto>> ListBinsAsync(long warehouseId, CancellationToken ct = default);
Task<BinDto> CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default);
}
+282
View File
@@ -0,0 +1,282 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
/// docs/11-BACKEND-PHASE1.md §2.12.2 and 02-SECURITY C.1.
/// </summary>
public sealed class ItemService : IItemService
{
private readonly IRepository<Item> _items;
private readonly IRepository<Category> _categories;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IUnitOfWork _uow;
public ItemService(
IRepository<Item> items,
IRepository<Category> categories,
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IUnitOfWork uow)
{
_items = items;
_categories = categories;
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_uow = uow;
}
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<ItemDetailDto>?> GetAsync(long itemId, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default)
{
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
var item = new Item
{
Sku = request.Sku.Trim(),
Name = request.Name.Trim(),
Description = request.Description,
CategoryId = request.CategoryId,
BaseUomId = request.BaseUomId,
DefaultVendorId = request.DefaultVendorId,
ItemType = request.ItemType,
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _items.AddAsync(item, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task<ETagged<ItemDetailDto>> UpdateAsync(
long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
if (item.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
if (!string.Equals(item.Sku, request.Sku, StringComparison.Ordinal)
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
item.CategoryId = request.CategoryId;
item.BaseUomId = request.BaseUomId;
item.DefaultVendorId = request.DefaultVendorId;
item.ItemType = request.ItemType;
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
return new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
}
public async Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default)
{
var item = await _items.GetByIdAsync(itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
item.Status = status;
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
public async Task<ItemReorderSettingsDto> UpdateReorderAsync(
long itemId, UpdateReorderRequest request, CancellationToken ct = default)
{
if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400);
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var warehouseId in request.Settings.Select(s => s.WarehouseId))
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
// Full-replacement upsert (avoids delete+insert clashes on the unique index).
foreach (var stale in item.ReorderSettings.Where(r => request.Settings.All(s => s.WarehouseId != r.WarehouseId)).ToList())
item.ReorderSettings.Remove(stale);
foreach (var input in request.Settings)
{
var existing = item.ReorderSettings.FirstOrDefault(r => r.WarehouseId == input.WarehouseId);
if (existing is null)
{
item.ReorderSettings.Add(new ItemReorder
{
ItemId = itemId,
WarehouseId = input.WarehouseId,
ReorderPoint = input.ReorderPoint,
ReorderQty = input.ReorderQty
});
}
else
{
existing.ReorderPoint = input.ReorderPoint;
existing.ReorderQty = input.ReorderQty;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var settings = item.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList();
return new ItemReorderSettingsDto(settings);
}
public async Task<ItemUomConversionsDto> UpdateUomConversionsAsync(
long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default)
{
var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList();
if (pairs.Distinct().Count() != pairs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate (fromUom, toUom) in conversions.", 400);
var item = await _items.Query()
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
foreach (var uomId in request.Conversions.SelectMany(c => new[] { c.FromUom, c.ToUom }).Distinct())
if (!await _uoms.Query().AnyAsync(u => u.UomId == uomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {uomId} does not exist.", 422);
foreach (var stale in item.UomConversions.Where(c => request.Conversions.All(r => r.FromUom != c.FromUomId || r.ToUom != c.ToUomId)).ToList())
item.UomConversions.Remove(stale);
foreach (var input in request.Conversions)
{
var existing = item.UomConversions.FirstOrDefault(c => c.FromUomId == input.FromUom && c.ToUomId == input.ToUom);
if (existing is null)
{
item.UomConversions.Add(new UomConversion
{
ItemId = itemId,
FromUomId = input.FromUom,
ToUomId = input.ToUom,
Factor = input.Factor
});
}
else
{
existing.Factor = input.Factor;
}
}
item.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var conversions = item.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList();
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
private async Task ValidateReferencesAsync(long categoryId, long baseUomId, long? defaultVendorId, CancellationToken ct)
{
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
if (defaultVendorId is not null)
{
var vendor = await _vendors.Query().AsNoTracking()
.FirstOrDefaultAsync(v => v.VendorId == defaultVendorId, ct);
if (vendor is null)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} does not exist.", 422);
if (vendor.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Vendor {defaultVendorId} is inactive.", 422);
}
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item was modified by another request.", 412);
}
}
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
+53
View File
@@ -0,0 +1,53 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class UomService : IUomService
{
private readonly IRepository<Uom> _uoms;
private readonly IUnitOfWork _uow;
public UomService(IRepository<Uom> uoms, IUnitOfWork uow)
{
_uoms = uoms;
_uow = uow;
}
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default)
{
var name = request.Name.Trim();
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name };
await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name);
}
}
+118
View File
@@ -0,0 +1,118 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class VendorService : IVendorService
{
private readonly IRepository<Vendor> _vendors;
private readonly IUnitOfWork _uow;
public VendorService(IRepository<Vendor> vendors, IUnitOfWork uow)
{
_vendors = vendors;
_uow = uow;
}
public async Task<PagedResponse<VendorDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _vendors.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(v => EF.Functions.ILike(v.Code, $"%{term}%") || EF.Functions.ILike(v.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(v => v.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(v => v.Code)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<VendorDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<VendorDto>?> GetAsync(long vendorId, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking()
.FirstOrDefaultAsync(v => v.VendorId == vendorId, ct);
return vendor is null ? null : new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _vendors.Query().AnyAsync(v => v.Code == code, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
var vendor = new Vendor
{
Code = code,
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _vendors.AddAsync(vendor, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task<ETagged<VendorDto>> UpdateAsync(
long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var vendor = await _vendors.GetByIdAsync(vendorId, ct)
?? throw new NotFoundException($"Vendor {vendorId} was not found.");
if (vendor.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The vendor was modified by another request.", 412);
var code = request.Code.Trim();
if (!string.Equals(vendor.Code, code, StringComparison.Ordinal)
&& await _vendors.Query().AnyAsync(v => v.Code == code && v.VendorId != vendorId, ct))
throw new ConflictException($"A vendor with code '{code}' already exists.");
vendor.Code = code;
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The vendor was modified by another request.", 412);
}
return new ETagged<VendorDto>(Map(vendor), vendor.RowVersion);
}
public async Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default)
{
var vendor = await _vendors.GetByIdAsync(vendorId, ct)
?? throw new NotFoundException($"Vendor {vendorId} was not found.");
vendor.Status = status;
vendor.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.CreatedAt, v.UpdatedAt);
}
@@ -0,0 +1,94 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class WarehouseService : IWarehouseService
{
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IUnitOfWork _uow;
public WarehouseService(IRepository<Warehouse> warehouses, IRepository<Bin> bins, IUnitOfWork uow)
{
_warehouses = warehouses;
_bins = bins;
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _warehouses.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name))
.ToListAsync(ct);
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<WarehouseDto?> GetAsync(long warehouseId, CancellationToken ct = default)
{
var w = await _warehouses.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name);
}
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
throw new ConflictException($"A warehouse with code '{code}' already exists.");
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim() };
await _warehouses.AddAsync(warehouse, ct);
await _uow.SaveChangesAsync(ct);
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name);
}
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(long warehouseId, CancellationToken ct = default)
{
await EnsureWarehouseExistsAsync(warehouseId, ct);
return await _bins.Query().AsNoTracking()
.Where(b => b.WarehouseId == warehouseId)
.OrderBy(b => b.Code)
.Select(b => new BinDto(b.BinId, b.WarehouseId, b.Code, b.BinType))
.ToListAsync(ct);
}
public async Task<BinDto> CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default)
{
await EnsureWarehouseExistsAsync(warehouseId, ct);
var code = request.Code.Trim();
if (await _bins.Query().AnyAsync(b => b.WarehouseId == warehouseId && b.Code == code, ct))
throw new ConflictException($"Bin '{code}' already exists in warehouse {warehouseId}.");
var bin = new Bin { WarehouseId = warehouseId, Code = code, BinType = request.BinType };
await _bins.AddAsync(bin, ct);
await _uow.SaveChangesAsync(ct);
return new BinDto(bin.BinId, bin.WarehouseId, bin.Code, bin.BinType);
}
private async Task EnsureWarehouseExistsAsync(long warehouseId, CancellationToken ct)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
}
}