completed invetory updates
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency
|
||||
/// per docs/11-BACKEND-PHASE1.md §2.6.
|
||||
/// </summary>
|
||||
public sealed class BrandService : IBrandService
|
||||
{
|
||||
private readonly IRepository<Brand> _brands;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
|
||||
{
|
||||
_brands = brands;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _brands.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(b => b.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(b => b.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<BrandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
var brand = new Brand
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _brands.AddAsync(brand, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>> UpdateAsync(
|
||||
int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||
|
||||
if (brand.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
|
||||
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
brand.Name = name;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||
|
||||
brand.Status = status;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
|
||||
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// </summary>
|
||||
public sealed class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly IRepository<Category> _categories;
|
||||
private readonly IRepository<SubCategory> _subCategories;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
|
||||
public CategoryService(
|
||||
IRepository<Category> categories,
|
||||
IRepository<SubCategory> subCategories,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_categories = categories;
|
||||
_subCategories = subCategories;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
// Categories ---------------------------------------------------------------
|
||||
|
||||
public async Task<PagedResponse<CategoryDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _categories.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
|
||||
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))
|
||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
|
||||
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, 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(int? parentId) =>
|
||||
byParent[parentId]
|
||||
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
|
||||
.ToList();
|
||||
|
||||
return Build(null);
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||
public async Task<ETagged<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 name = request.Name.Trim();
|
||||
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
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);
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CategoryDto>> UpdateAsync(
|
||||
int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
if (category.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
|
||||
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
category.Name = name;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("category", ct);
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
category.Status = status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Subcategories ------------------------------------------------------------
|
||||
|
||||
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(s => s.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(s => s.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(s => new SubCategoryDto(
|
||||
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
|
||||
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
if (category.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
|
||||
|
||||
var sub = new SubCategory
|
||||
{
|
||||
CategoryId = categoryId,
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _subCategories.AddAsync(sub, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
|
||||
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
if (sub.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
|
||||
&& await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
|
||||
|
||||
// Name only — reparenting is not offered, since it would silently invalidate the
|
||||
// category of every item pointing at this subcategory.
|
||||
sub.Name = name;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("subcategory", ct);
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
sub.Status = status;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt);
|
||||
|
||||
private static SubCategoryDto MapSub(SubCategory s) => new(
|
||||
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
public interface IBrandService
|
||||
{
|
||||
Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
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>
|
||||
/// <summary>
|
||||
/// Category + subcategory master business logic (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// The hierarchy is exactly two levels deep; there is no tree endpoint any more.
|
||||
/// </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);
|
||||
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Subcategories of one category. 404s when the category itself does not exist.</summary>
|
||||
Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
|
||||
Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface IItemService
|
||||
{
|
||||
Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master business logic (docs/11-BACKEND-PHASE1.md §2.7). Plain CRUD over an
|
||||
/// unlinked list — no item ever references an item type (docs/10 Part C.9), so there is
|
||||
/// nothing here beyond maintaining the names the builder's dropdown reads.
|
||||
/// </summary>
|
||||
public interface IItemTypeService
|
||||
{
|
||||
Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton.</summary>
|
||||
public interface IProductConfigService
|
||||
{
|
||||
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -13,36 +13,52 @@ 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.1–2.2 and 02-SECURITY C.1.
|
||||
/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic
|
||||
/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2
|
||||
/// and 02-SECURITY C.1.
|
||||
/// <para>
|
||||
/// The SKU arrives generated by the client (it encodes the chosen item-type values,
|
||||
/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no
|
||||
/// item-type reference at all — see docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ItemService : IItemService
|
||||
{
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Category> _categories;
|
||||
private readonly IRepository<SubCategory> _subCategories;
|
||||
private readonly IRepository<Brand> _brands;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IProductConfigService _config;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemService(
|
||||
IRepository<Item> items,
|
||||
IRepository<Category> categories,
|
||||
IRepository<SubCategory> subCategories,
|
||||
IRepository<Brand> brands,
|
||||
IRepository<Uom> uoms,
|
||||
IRepository<Vendor> vendors,
|
||||
IRepository<Warehouse> warehouses,
|
||||
IProductConfigService config,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_items = items;
|
||||
_categories = categories;
|
||||
_subCategories = subCategories;
|
||||
_brands = brands;
|
||||
_uoms = uoms;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_config = config;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default)
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
|
||||
@@ -53,14 +69,17 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
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 (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
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))
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -80,7 +99,9 @@ public sealed class ItemService : IItemService
|
||||
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);
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -88,9 +109,11 @@ public sealed class ItemService : IItemService
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description,
|
||||
CategoryId = request.CategoryId,
|
||||
SubCategoryId = request.SubCategoryId,
|
||||
BrandId = request.BrandId,
|
||||
BaseUomId = request.BaseUomId,
|
||||
DefaultVendorId = request.DefaultVendorId,
|
||||
ItemType = request.ItemType,
|
||||
StockNature = request.StockNature,
|
||||
TrackingMode = request.TrackingMode,
|
||||
TaxClass = request.TaxClass,
|
||||
Status = EntityStatus.Active,
|
||||
@@ -118,15 +141,19 @@ public sealed class ItemService : IItemService
|
||||
&& 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);
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
item.Description = request.Description;
|
||||
item.CategoryId = request.CategoryId;
|
||||
item.SubCategoryId = request.SubCategoryId;
|
||||
item.BrandId = request.BrandId;
|
||||
item.BaseUomId = request.BaseUomId;
|
||||
item.DefaultVendorId = request.DefaultVendorId;
|
||||
item.ItemType = request.ItemType;
|
||||
item.StockNature = request.StockNature;
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.TaxClass = request.TaxClass;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -240,11 +267,56 @@ public sealed class ItemService : IItemService
|
||||
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Validates every FK on an item write, and gates the optional ones on the product
|
||||
/// configuration (FR-MD-11). Note there is no item-type check: nothing on an item
|
||||
/// references an item type, so <c>itemTypesEnabled</c> has nothing to reject here —
|
||||
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
|
||||
/// </summary>
|
||||
private async Task ValidateReferencesAsync(
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
||||
{
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
||||
|
||||
if (subCategoryId is not null)
|
||||
{
|
||||
if (!config.SubcategoriesEnabled)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
|
||||
|
||||
var sub = await _subCategories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
if (sub is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
|
||||
if (sub.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
|
||||
// The two FKs must agree, or the item would claim a category its subcategory
|
||||
// does not belong to.
|
||||
if (sub.CategoryId != categoryId)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
|
||||
}
|
||||
|
||||
if (brandId is not null)
|
||||
{
|
||||
if (!config.BrandsEnabled)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Brands are disabled in the product configuration; brandId must be null.", 422);
|
||||
|
||||
var brand = await _brands.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
if (brand is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
|
||||
if (brand.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422);
|
||||
}
|
||||
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
|
||||
|
||||
@@ -272,8 +344,9 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
|
||||
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.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
|
||||
i.ReorderSettings
|
||||
.OrderBy(r => r.WarehouseId)
|
||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that
|
||||
/// <c>GET /item-types</c> serves to the frontend builder's dropdown. Nothing references
|
||||
/// these rows, so there is no in-use check to make and no cascade to worry about
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9).
|
||||
/// </summary>
|
||||
public sealed class ItemTypeService : IItemTypeService
|
||||
{
|
||||
private readonly IRepository<ItemType> _itemTypes;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ItemTypeService(IRepository<ItemType> itemTypes, IUnitOfWork uow)
|
||||
{
|
||||
_itemTypes = itemTypes;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _itemTypes.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(t => t.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(t => t.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default)
|
||||
{
|
||||
var itemType = await _itemTypes.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct);
|
||||
return itemType is null ? null : new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
|
||||
var itemType = new ItemType
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _itemTypes.AddAsync(itemType, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemTypeDto>> UpdateAsync(
|
||||
int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct)
|
||||
?? throw new NotFoundException($"Item type {itemTypeId} was not found.");
|
||||
|
||||
if (itemType.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(itemType.Name, name, StringComparison.Ordinal)
|
||||
&& await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct))
|
||||
throw new ConflictException($"An item type named '{name}' already exists.");
|
||||
|
||||
// Renaming does not touch existing items: their SKUs already encode the values that
|
||||
// were chosen, and nothing joins back to this row (docs/10 Part C.9).
|
||||
itemType.Name = name;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct)
|
||||
?? throw new NotFoundException($"Item type {itemTypeId} was not found.");
|
||||
|
||||
itemType.Status = status;
|
||||
itemType.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
|
||||
/// Reads are never gated — only writes consult the flags — so switching a feature off
|
||||
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
|
||||
/// </summary>
|
||||
public sealed class ProductConfigService : IProductConfigService
|
||||
{
|
||||
private readonly IRepository<ProductConfig> _config;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ProductConfigService(IRepository<ProductConfig> config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Product configuration has not been seeded.");
|
||||
|
||||
return new ETagged<ProductConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(ProductConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Product configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the
|
||||
// model binder has already rejected anything that would reach here with a null.
|
||||
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
|
||||
config.BrandsEnabled = request.BrandsEnabled!.Value;
|
||||
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<ProductConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
Reference in New Issue
Block a user