item mng with sync

This commit is contained in:
Kalana B. Thilakarathna
2026-08-19 12:49:04 +05:30
parent 45f374133f
commit 397af8fd59
42 changed files with 5191 additions and 16 deletions
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Brand;
[ApiController]
[Route("api/brands")]
[Authorize]
public class BrandController(BrandService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
+5
View File
@@ -0,0 +1,5 @@
namespace Distribution.Features.Brand;
public record CreateBrandRequest(string Name);
public record UpdateBrandRequest(string Name);
public record BrandResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
@@ -0,0 +1,31 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Brand;
public class BrandRepository(AppDBContext context) : IBrandRepository
{
public async Task<Models.Brand?> GetByIdAsync(Guid id) =>
await context.Brands.FindAsync(id);
public async Task<List<Models.Brand>> GetAllAsync() =>
await context.Brands.ToListAsync();
public async Task<Models.Brand?> GetByExternalErpIdAsync(int erpId) =>
await context.Brands.FirstOrDefaultAsync(b => b.ExternalErpId == erpId);
public async Task<bool> HasActiveItemsAsync(Guid id) =>
await context.Items.AnyAsync(i => i.FK_BrandId == id && i.IsActive);
public async Task AddAsync(Models.Brand entity) =>
await context.Brands.AddAsync(entity);
public void Update(Models.Brand entity) =>
context.Brands.Update(entity);
public void Remove(Models.Brand entity)
{
entity.IsActive = false;
context.Brands.Update(entity);
}
}
@@ -0,0 +1,52 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Brand;
public class BrandService(IBrandRepository repo, IUnitOfWork uow)
{
public async Task<List<BrandResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(b => b.IsActive)
.Select(b => new BrandResponse(b.Id, b.Name, b.IsActive, b.CreatedAt))
.ToList();
public async Task<BrandResponse?> GetByIdAsync(Guid id)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null || !brand.IsActive) return null;
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
}
public async Task<BrandResponse> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
{
var brand = new Models.Brand { Name = request.Name };
await uow.BeginAsync(ct);
try { await repo.AddAsync(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
}
public async Task<BrandResponse?> UpdateAsync(Guid id, UpdateBrandRequest request, CancellationToken ct = default)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null || !brand.IsActive) return null;
if (brand.ExternalErpId.HasValue) return null;
brand.Name = request.Name;
await uow.BeginAsync(ct);
try { repo.Update(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null || !brand.IsActive) return false;
if (brand.ExternalErpId.HasValue) return false;
if (await repo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { repo.Remove(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.Brand;
public interface IBrandRepository
{
Task<Models.Brand?> GetByIdAsync(Guid id);
Task<List<Models.Brand>> GetAllAsync();
Task<Models.Brand?> GetByExternalErpIdAsync(int erpId);
Task<bool> HasActiveItemsAsync(Guid id);
Task AddAsync(Models.Brand entity);
void Update(Models.Brand entity);
void Remove(Models.Brand entity);
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Category;
[ApiController]
[Authorize]
public class CategoryController(CategoryService service) : ControllerBase
{
[HttpGet("api/categories")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/categories/{id:guid}")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/categories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("api/categories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("api/categories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateSubCategoryAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteSubCategoryAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Features.Category;
public record CreateCategoryRequest(string Name);
public record UpdateCategoryRequest(string Name);
public record CreateSubCategoryRequest(string Name);
public record UpdateSubCategoryRequest(string Name);
public record SubCategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record CategorySummaryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record CategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt, List<SubCategoryResponse> SubCategories);
@@ -0,0 +1,34 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Category;
public class CategoryRepository(AppDBContext context) : ICategoryRepository
{
public async Task<Models.Category?> GetByIdAsync(Guid id) =>
await context.Categories.FindAsync(id);
public async Task<List<Models.Category>> GetAllAsync() =>
await context.Categories.ToListAsync();
public async Task<Models.Category?> GetByExternalErpIdAsync(int erpId) =>
await context.Categories.FirstOrDefaultAsync(c => c.ExternalErpId == erpId);
public async Task<bool> HasActiveSubCategoriesAsync(Guid id) =>
await context.SubCategories.AnyAsync(s => s.FK_CategoryId == id && s.IsActive);
public async Task<bool> HasActiveItemsAsync(Guid id) =>
await context.Items.AnyAsync(i => i.FK_CategoryId == id && i.IsActive);
public async Task AddAsync(Models.Category entity) =>
await context.Categories.AddAsync(entity);
public void Update(Models.Category entity) =>
context.Categories.Update(entity);
public void Remove(Models.Category entity)
{
entity.IsActive = false;
context.Categories.Update(entity);
}
}
@@ -0,0 +1,92 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Category;
public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepository subCategoryRepo, IUnitOfWork uow)
{
public async Task<List<CategorySummaryResponse>> GetAllAsync() =>
(await categoryRepo.GetAllAsync())
.Where(c => c.IsActive)
.Select(c => new CategorySummaryResponse(c.Id, c.Name, c.IsActive, c.CreatedAt))
.ToList();
public async Task<CategoryResponse?> GetByIdAsync(Guid id)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null || !category.IsActive) return null;
var subs = (await subCategoryRepo.GetByCategoryIdAsync(id))
.Where(s => s.IsActive)
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
.ToList();
return new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs);
}
public async Task<CategorySummaryResponse> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{
var category = new Models.Category { Name = request.Name };
await uow.BeginAsync(ct);
try { await categoryRepo.AddAsync(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new CategorySummaryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt);
}
public async Task<CategorySummaryResponse?> UpdateAsync(Guid id, UpdateCategoryRequest request, CancellationToken ct = default)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null || !category.IsActive) return null;
if (category.ExternalErpId.HasValue) return null;
category.Name = request.Name;
await uow.BeginAsync(ct);
try { categoryRepo.Update(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new CategorySummaryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null || !category.IsActive) return false;
if (category.ExternalErpId.HasValue) return false;
if (await categoryRepo.HasActiveSubCategoriesAsync(id)) return false;
if (await categoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { categoryRepo.Remove(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
public async Task<SubCategoryResponse?> CreateSubCategoryAsync(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
{
var category = await categoryRepo.GetByIdAsync(categoryId);
if (category is null || !category.IsActive) return null;
var sub = new Models.SubCategory { Name = request.Name, FK_CategoryId = categoryId };
await uow.BeginAsync(ct);
try { await subCategoryRepo.AddAsync(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new SubCategoryResponse(sub.Id, sub.Name, sub.IsActive, sub.CreatedAt);
}
public async Task<SubCategoryResponse?> UpdateSubCategoryAsync(Guid id, UpdateSubCategoryRequest request, CancellationToken ct = default)
{
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null || !sub.IsActive) return null;
if (sub.ExternalErpId.HasValue) return null;
sub.Name = request.Name;
await uow.BeginAsync(ct);
try { subCategoryRepo.Update(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new SubCategoryResponse(sub.Id, sub.Name, sub.IsActive, sub.CreatedAt);
}
public async Task<bool> DeleteSubCategoryAsync(Guid id, CancellationToken ct = default)
{
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null || !sub.IsActive) return false;
if (sub.ExternalErpId.HasValue) return false;
if (await subCategoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { subCategoryRepo.Remove(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
}
@@ -0,0 +1,13 @@
namespace Distribution.Features.Category;
public interface ICategoryRepository
{
Task<Models.Category?> GetByIdAsync(Guid id);
Task<List<Models.Category>> GetAllAsync();
Task<Models.Category?> GetByExternalErpIdAsync(int erpId);
Task<bool> HasActiveSubCategoriesAsync(Guid id);
Task<bool> HasActiveItemsAsync(Guid id);
Task AddAsync(Models.Category entity);
void Update(Models.Category entity);
void Remove(Models.Category entity);
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.Category;
public interface ISubCategoryRepository
{
Task<Models.SubCategory?> GetByIdAsync(Guid id);
Task<List<Models.SubCategory>> GetByCategoryIdAsync(Guid categoryId);
Task<Models.SubCategory?> GetByExternalErpIdAsync(int erpId);
Task<bool> HasActiveItemsAsync(Guid id);
Task AddAsync(Models.SubCategory entity);
void Update(Models.SubCategory entity);
void Remove(Models.SubCategory entity);
}
@@ -0,0 +1,31 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Category;
public class SubCategoryRepository(AppDBContext context) : ISubCategoryRepository
{
public async Task<Models.SubCategory?> GetByIdAsync(Guid id) =>
await context.SubCategories.FindAsync(id);
public async Task<List<Models.SubCategory>> GetByCategoryIdAsync(Guid categoryId) =>
await context.SubCategories.Where(s => s.FK_CategoryId == categoryId).ToListAsync();
public async Task<Models.SubCategory?> GetByExternalErpIdAsync(int erpId) =>
await context.SubCategories.FirstOrDefaultAsync(s => s.ExternalErpId == erpId);
public async Task<bool> HasActiveItemsAsync(Guid id) =>
await context.Items.AnyAsync(i => i.FK_SubCategoryId == id && i.IsActive);
public async Task AddAsync(Models.SubCategory entity) =>
await context.SubCategories.AddAsync(entity);
public void Update(Models.SubCategory entity) =>
context.SubCategories.Update(entity);
public void Remove(Models.SubCategory entity)
{
entity.IsActive = false;
context.SubCategories.Update(entity);
}
}
@@ -0,0 +1,13 @@
namespace Distribution.Features.Item;
public interface IItemRepository
{
Task<Models.Item?> GetByIdAsync(Guid id);
Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null);
Task<bool> SkuExistsAsync(string sku, Guid? excludeId = null);
Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId);
Task AddAsync(Models.Item entity);
void Update(Models.Item entity);
void Remove(Models.Item entity);
Task ReplaceConversionsAsync(Guid itemId, List<Models.UomConversion> conversions);
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Item;
[ApiController]
[Route("api/items")]
[Authorize]
public class ItemController(ItemService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? categoryId,
[FromQuery] Guid? subCategoryId,
[FromQuery] Guid? brandId) =>
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/uom-conversions")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
{
var result = await service.ReplaceConversionsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
+63
View File
@@ -0,0 +1,63 @@
namespace Distribution.Features.Item;
public record UomConversionRequest(Guid FromUomId, Guid ToUomId, decimal Factor);
public record CreateItemRequest(
string Sku,
string Name,
string? Description,
Guid CategoryId,
Guid? SubCategoryId,
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
List<UomConversionRequest>? Conversions);
public record UpdateItemRequest(
string Name,
string? Description,
Guid CategoryId,
Guid? SubCategoryId,
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
public record UomConversionResponse(Guid Id, Guid FromUomId, string FromUomName, Guid ToUomId, string ToUomName, decimal Factor);
public record ItemSummaryResponse(
Guid Id,
string Sku,
string Name,
Guid CategoryId,
string CategoryName,
Guid? SubCategoryId,
string? SubCategoryName,
Guid? BrandId,
string? BrandName,
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt);
public record ItemDetailResponse(
Guid Id,
string Sku,
string Name,
string? Description,
Guid CategoryId,
string CategoryName,
Guid? SubCategoryId,
string? SubCategoryName,
Guid? BrandId,
string? BrandName,
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt,
List<UomConversionResponse> Conversions);
@@ -0,0 +1,64 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Item;
public class ItemRepository(AppDBContext context) : IItemRepository
{
public async Task<Models.Item?> GetByIdAsync(Guid id) =>
await context.Items
.Include(i => i.Category)
.Include(i => i.SubCategory)
.Include(i => i.Brand)
.Include(i => i.BaseUom)
.Include(i => i.Conversions).ThenInclude(c => c.FromUom)
.Include(i => i.Conversions).ThenInclude(c => c.ToUom)
.FirstOrDefaultAsync(i => i.Id == id);
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
{
var query = context.Items
.Include(i => i.Category)
.Include(i => i.SubCategory)
.Include(i => i.Brand)
.Include(i => i.BaseUom)
.Where(i => i.IsActive);
if (categoryId.HasValue) query = query.Where(i => i.FK_CategoryId == categoryId);
if (subCategoryId.HasValue) query = query.Where(i => i.FK_SubCategoryId == subCategoryId);
if (brandId.HasValue) query = query.Where(i => i.FK_BrandId == brandId);
return await query.ToListAsync();
}
public async Task<bool> SkuExistsAsync(string sku, Guid? excludeId = null)
{
var query = context.Items.Where(i => i.Sku == sku);
if (excludeId.HasValue) query = query.Where(i => i.Id != excludeId);
return await query.AnyAsync();
}
public async Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId) =>
await context.Items
.Include(i => i.Conversions)
.FirstOrDefaultAsync(i => i.ExternalErpItemId == erpId);
public async Task AddAsync(Models.Item entity) =>
await context.Items.AddAsync(entity);
public void Update(Models.Item entity) =>
context.Items.Update(entity);
public void Remove(Models.Item entity)
{
entity.IsActive = false;
context.Items.Update(entity);
}
public async Task ReplaceConversionsAsync(Guid itemId, List<Models.UomConversion> conversions)
{
var existing = await context.UomConversions.Where(c => c.FK_ItemId == itemId).ToListAsync();
context.UomConversions.RemoveRange(existing);
await context.UomConversions.AddRangeAsync(conversions);
}
}
+126
View File
@@ -0,0 +1,126 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow)
{
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
private static ItemSummaryResponse MapSummary(Models.Item i) =>
new(i.Id, i.Sku, i.Name,
i.FK_CategoryId, i.Category.Name,
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description,
i.FK_CategoryId, i.Category.Name,
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList());
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary)
.ToList();
public async Task<ItemDetailResponse?> GetByIdAsync(Guid id)
{
var item = await repo.GetByIdAsync(id);
if (item is null || !item.IsActive) return null;
return MapDetail(item);
}
public async Task<ItemDetailResponse> CreateAsync(CreateItemRequest request, CancellationToken ct = default)
{
if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
var item = new Models.Item
{
Sku = request.Sku,
Name = request.Name,
Description = request.Description,
FK_CategoryId = request.CategoryId,
FK_SubCategoryId = request.SubCategoryId,
FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice,
SyncSource = Models.SyncSource.Manual
};
var conversions = (request.Conversions ?? []).Select(c => new Models.UomConversion
{
FK_ItemId = item.Id,
FK_FromUomId = c.FromUomId,
FK_ToUomId = c.ToUomId,
Factor = c.Factor
}).ToList();
item.Conversions = conversions;
await uow.BeginAsync(ct);
try { await repo.AddAsync(item); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(item.Id))!;
}
public async Task<ItemDetailResponse?> UpdateAsync(Guid id, UpdateItemRequest request, CancellationToken ct = default)
{
var item = await repo.GetByIdAsync(id);
if (item is null || !item.IsActive) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
item.Name = request.Name;
item.Description = request.Description;
item.FK_CategoryId = request.CategoryId;
item.FK_SubCategoryId = request.SubCategoryId;
item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
await uow.BeginAsync(ct);
try { repo.Update(item); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var item = await repo.GetByIdAsync(id);
if (item is null || !item.IsActive) return false;
if (item.SyncSource == Models.SyncSource.Erp) return false;
await uow.BeginAsync(ct);
try { repo.Remove(item); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
public async Task<List<UomConversionResponse>?> ReplaceConversionsAsync(Guid id, ReplaceConversionsRequest request, CancellationToken ct = default)
{
var item = await repo.GetByIdAsync(id);
if (item is null || !item.IsActive) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
var conversions = request.Conversions.Select(c => new Models.UomConversion
{
FK_ItemId = id,
FK_FromUomId = c.FromUomId,
FK_ToUomId = c.ToUomId,
Factor = c.Factor
}).ToList();
await uow.BeginAsync(ct);
try { await repo.ReplaceConversionsAsync(id, conversions); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!.Conversions;
}
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sync;
[ApiController]
[Route("api/sync")]
[Authorize(Policy = "items.create")]
public class SyncController(SyncService service) : ControllerBase
{
[HttpPost("uoms")]
public async Task<IActionResult> SyncUoms(SyncUomsPayload payload, CancellationToken ct) =>
Ok(await service.SyncUomsAsync(payload, ct));
[HttpPost("categories")]
public async Task<IActionResult> SyncCategories(SyncCategoriesPayload payload, CancellationToken ct) =>
Ok(await service.SyncCategoriesAsync(payload, ct));
[HttpPost("brands")]
public async Task<IActionResult> SyncBrands(SyncBrandsPayload payload, CancellationToken ct) =>
Ok(await service.SyncBrandsAsync(payload, ct));
[HttpPost("items")]
public async Task<IActionResult> SyncItems(SyncItemsPayload payload, CancellationToken ct) =>
Ok(await service.SyncItemsAsync(payload, ct));
}
+26
View File
@@ -0,0 +1,26 @@
namespace Distribution.Features.Sync;
public record SyncUomRequest(int ErpId, string Name);
public record SyncUomsPayload(List<SyncUomRequest> Items);
public record SyncSubCategoryRequest(int ErpId, string Name, int ParentErpId);
public record SyncCategoryRequest(int ErpId, string Name, List<SyncSubCategoryRequest> SubCategories);
public record SyncCategoriesPayload(List<SyncCategoryRequest> Items);
public record SyncBrandRequest(int ErpId, string Name);
public record SyncBrandsPayload(List<SyncBrandRequest> Items);
public record SyncItemRequest(
int ErpId,
string Sku,
string Name,
string? Description,
int CategoryErpId,
int? SubCategoryErpId,
int? BrandErpId,
int BaseUomErpId,
decimal? SalePrice,
bool IsActive);
public record SyncItemsPayload(List<SyncItemRequest> Items);
public record SyncResult(int Inserted, int Updated, int Skipped);
+186
View File
@@ -0,0 +1,186 @@
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.Uom;
using Distribution.Features.Item;
using Distribution.Infra.UoW;
namespace Distribution.Features.Sync;
public class SyncService(
ICategoryRepository categoryRepo,
ISubCategoryRepository subCategoryRepo,
IBrandRepository brandRepo,
IUomRepository uomRepo,
IItemRepository itemRepo,
IUnitOfWork uow)
{
public async Task<SyncResult> SyncUomsAsync(SyncUomsPayload payload, CancellationToken ct = default)
{
int inserted = 0, updated = 0;
await uow.BeginAsync(ct);
try
{
foreach (var u in payload.Items)
{
var existing = await uomRepo.GetByExternalErpIdAsync(u.ErpId);
if (existing is null)
{
await uomRepo.AddAsync(new Models.Uom { Name = u.Name, ExternalErpId = u.ErpId });
inserted++;
}
else
{
existing.Name = u.Name;
uomRepo.Update(existing);
updated++;
}
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return new SyncResult(inserted, updated, 0);
}
public async Task<SyncResult> SyncCategoriesAsync(SyncCategoriesPayload payload, CancellationToken ct = default)
{
int inserted = 0, updated = 0;
await uow.BeginAsync(ct);
try
{
foreach (var c in payload.Items)
{
var existingCat = await categoryRepo.GetByExternalErpIdAsync(c.ErpId);
Guid categoryId;
if (existingCat is null)
{
var newCat = new Models.Category { Name = c.Name, ExternalErpId = c.ErpId };
await categoryRepo.AddAsync(newCat);
categoryId = newCat.Id;
inserted++;
}
else
{
existingCat.Name = c.Name;
categoryRepo.Update(existingCat);
categoryId = existingCat.Id;
updated++;
}
foreach (var s in c.SubCategories)
{
var existingSub = await subCategoryRepo.GetByExternalErpIdAsync(s.ErpId);
if (existingSub is null)
{
await subCategoryRepo.AddAsync(new Models.SubCategory
{
Name = s.Name,
FK_CategoryId = categoryId,
ExternalErpId = s.ErpId
});
inserted++;
}
else
{
existingSub.Name = s.Name;
existingSub.FK_CategoryId = categoryId;
subCategoryRepo.Update(existingSub);
updated++;
}
}
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return new SyncResult(inserted, updated, 0);
}
public async Task<SyncResult> SyncBrandsAsync(SyncBrandsPayload payload, CancellationToken ct = default)
{
int inserted = 0, updated = 0;
await uow.BeginAsync(ct);
try
{
foreach (var b in payload.Items)
{
var existing = await brandRepo.GetByExternalErpIdAsync(b.ErpId);
if (existing is null)
{
await brandRepo.AddAsync(new Models.Brand { Name = b.Name, ExternalErpId = b.ErpId });
inserted++;
}
else
{
existing.Name = b.Name;
brandRepo.Update(existing);
updated++;
}
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return new SyncResult(inserted, updated, 0);
}
public async Task<SyncResult> SyncItemsAsync(SyncItemsPayload payload, CancellationToken ct = default)
{
int inserted = 0, updated = 0, skipped = 0;
await uow.BeginAsync(ct);
try
{
foreach (var i in payload.Items)
{
var category = await categoryRepo.GetByExternalErpIdAsync(i.CategoryErpId);
var baseUom = await uomRepo.GetByExternalErpIdAsync(i.BaseUomErpId);
if (category is null || baseUom is null) { skipped++; continue; }
Models.SubCategory? subCategory = null;
if (i.SubCategoryErpId.HasValue)
subCategory = await subCategoryRepo.GetByExternalErpIdAsync(i.SubCategoryErpId.Value);
Models.Brand? brand = null;
if (i.BrandErpId.HasValue)
brand = await brandRepo.GetByExternalErpIdAsync(i.BrandErpId.Value);
var existing = await itemRepo.GetByExternalErpItemIdAsync(i.ErpId);
if (existing is null)
{
await itemRepo.AddAsync(new Models.Item
{
Sku = i.Sku,
Name = i.Name,
Description = i.Description,
FK_CategoryId = category.Id,
FK_SubCategoryId = subCategory?.Id,
FK_BrandId = brand?.Id,
FK_BaseUomId = baseUom.Id,
SalePrice = i.SalePrice,
IsActive = i.IsActive,
SyncSource = Models.SyncSource.Erp,
ExternalErpItemId = i.ErpId,
LastSyncedAt = DateTime.UtcNow
});
inserted++;
}
else
{
existing.Sku = i.Sku;
existing.Name = i.Name;
existing.Description = i.Description;
existing.FK_CategoryId = category.Id;
existing.FK_SubCategoryId = subCategory?.Id;
existing.FK_BrandId = brand?.Id;
existing.FK_BaseUomId = baseUom.Id;
existing.SalePrice = i.SalePrice;
existing.IsActive = i.IsActive;
existing.LastSyncedAt = DateTime.UtcNow;
itemRepo.Update(existing);
updated++;
}
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return new SyncResult(inserted, updated, skipped);
}
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.Uom;
public interface IUomRepository
{
Task<Models.Uom?> GetByIdAsync(Guid id);
Task<List<Models.Uom>> GetAllAsync();
Task<Models.Uom?> GetByExternalErpIdAsync(int erpId);
Task<bool> IsUsedByItemsAsync(Guid id);
Task AddAsync(Models.Uom entity);
void Update(Models.Uom entity);
void Remove(Models.Uom entity);
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Uom;
[ApiController]
[Route("api/uoms")]
[Authorize]
public class UomController(UomService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
+5
View File
@@ -0,0 +1,5 @@
namespace Distribution.Features.Uom;
public record CreateUomRequest(string Name);
public record UpdateUomRequest(string Name);
public record UomResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Uom;
public class UomRepository(AppDBContext context) : IUomRepository
{
public async Task<Models.Uom?> GetByIdAsync(Guid id) =>
await context.Uoms.FindAsync(id);
public async Task<List<Models.Uom>> GetAllAsync() =>
await context.Uoms.ToListAsync();
public async Task<Models.Uom?> GetByExternalErpIdAsync(int erpId) =>
await context.Uoms.FirstOrDefaultAsync(u => u.ExternalErpId == erpId);
public async Task<bool> IsUsedByItemsAsync(Guid id) =>
await context.Items.AnyAsync(i => i.FK_BaseUomId == id && i.IsActive) ||
await context.UomConversions.AnyAsync(c => c.FK_FromUomId == id || c.FK_ToUomId == id);
public async Task AddAsync(Models.Uom entity) =>
await context.Uoms.AddAsync(entity);
public void Update(Models.Uom entity) =>
context.Uoms.Update(entity);
public void Remove(Models.Uom entity)
{
entity.IsActive = false;
context.Uoms.Update(entity);
}
}
+52
View File
@@ -0,0 +1,52 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Uom;
public class UomService(IUomRepository repo, IUnitOfWork uow)
{
public async Task<List<UomResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(u => u.IsActive)
.Select(u => new UomResponse(u.Id, u.Name, u.IsActive, u.CreatedAt))
.ToList();
public async Task<UomResponse?> GetByIdAsync(Guid id)
{
var uom = await repo.GetByIdAsync(id);
if (uom is null || !uom.IsActive) return null;
return new UomResponse(uom.Id, uom.Name, uom.IsActive, uom.CreatedAt);
}
public async Task<UomResponse> CreateAsync(CreateUomRequest request, CancellationToken ct = default)
{
var uom = new Models.Uom { Name = request.Name };
await uow.BeginAsync(ct);
try { await repo.AddAsync(uom); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new UomResponse(uom.Id, uom.Name, uom.IsActive, uom.CreatedAt);
}
public async Task<UomResponse?> UpdateAsync(Guid id, UpdateUomRequest request, CancellationToken ct = default)
{
var uom = await repo.GetByIdAsync(id);
if (uom is null || !uom.IsActive) return null;
if (uom.ExternalErpId.HasValue) return null;
uom.Name = request.Name;
await uow.BeginAsync(ct);
try { repo.Update(uom); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new UomResponse(uom.Id, uom.Name, uom.IsActive, uom.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var uom = await repo.GetByIdAsync(id);
if (uom is null || !uom.IsActive) return false;
if (uom.ExternalErpId.HasValue) return false;
if (await repo.IsUsedByItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { repo.Remove(uom); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
}