diff --git a/Distribution/Features/ShopPO/IShopPORepository.cs b/Distribution/Features/ShopPO/IShopPORepository.cs index 4a15739..3bf81a8 100644 --- a/Distribution/Features/ShopPO/IShopPORepository.cs +++ b/Distribution/Features/ShopPO/IShopPORepository.cs @@ -5,8 +5,8 @@ namespace Distribution.Features.ShopPO; public interface IShopPORepository { Task GetByIdAsync(Guid id); - Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize); - Task GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed); + Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize); + Task GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status); Task> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId); Task> GetActiveItemsByShopPOIdAsync(Guid shopPOId); Task> GetByStoreIdAsync(Guid storeId); diff --git a/Distribution/Features/ShopPO/ShopPOController.cs b/Distribution/Features/ShopPO/ShopPOController.cs index a7fb4c7..af13e30 100644 --- a/Distribution/Features/ShopPO/ShopPOController.cs +++ b/Distribution/Features/ShopPO/ShopPOController.cs @@ -1,21 +1,25 @@ -using Microsoft.AspNetCore.Mvc; +using Distribution.Infra.Authorization; +using Distribution.Models; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.ShopPO; [ApiController] [Route("api/shop-pos")] -public class ShopPOController(ShopPOService service) : ControllerBase +public class ShopPOController(ShopPOService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] public async Task GetAll( [FromQuery] Guid? agentId, [FromQuery] Guid? routeId, + [FromQuery] Guid? regionId, [FromQuery] Guid? shopId, [FromQuery] bool? confirmed, + [FromQuery] ShopPOStatus? status, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize); + var result = await service.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize); return Ok(result); } @@ -25,4 +29,66 @@ public class ShopPOController(ShopPOService service) : ControllerBase var result = await service.GetByIdAsync(id); return result is null ? NotFound() : Ok(result); } + + [HttpPost] + public async Task Create(CreateShopPORequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.CreateAsync(request, userId, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPut("{id:guid}/items")] + public async Task UpdateItems(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct) + { + try + { + var result = await service.UpdateItemsAsync(id, request, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPost("{id:guid}/confirm")] + public async Task Confirm(Guid id, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.ConfirmAsync(id, userId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPost("{id:guid}/confirm-and-create-invoice")] + public async Task ConfirmAndCreateInvoice(Guid id, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.ConfirmAndCreateInvoiceAsync(id, userId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPost("{id:guid}/cancel")] + public async Task Cancel(Guid id, CancellationToken ct) + { + try + { + var result = await service.CancelAsync(id, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } } diff --git a/Distribution/Features/ShopPO/ShopPORepository.cs b/Distribution/Features/ShopPO/ShopPORepository.cs index 2933fdc..271793c 100644 --- a/Distribution/Features/ShopPO/ShopPORepository.cs +++ b/Distribution/Features/ShopPO/ShopPORepository.cs @@ -13,27 +13,32 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository .ThenInclude(i => i.Item) .FirstOrDefaultAsync(p => p.Id == id && p.IsActive); - public async Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) => + public async Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize) => await context.ShopPOs - .Include(p => p.Store) + .Include(p => p.Store).ThenInclude(s => s.Route) + .Include(p => p.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item) .Where(p => p.IsActive && (agentId == null || p.RaisedById == agentId) && (routeId == null || p.Store.FK_RouteId == routeId) + && (regionId == null || p.Store.Route.FK_RegionId == regionId) && (shopId == null || p.FK_StoreId == shopId) - && (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))) + && (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)) + && (status == null || p.Status == status)) .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); - public async Task GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) => + public async Task GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status) => await context.ShopPOs - .Include(p => p.Store) + .Include(p => p.Store).ThenInclude(s => s.Route) .CountAsync(p => p.IsActive && (agentId == null || p.RaisedById == agentId) && (routeId == null || p.Store.FK_RouteId == routeId) + && (regionId == null || p.Store.Route.FK_RegionId == regionId) && (shopId == null || p.FK_StoreId == shopId) - && (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))); + && (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)) + && (status == null || p.Status == status)); public async Task> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) => await context.ShopPOs diff --git a/Distribution/Features/ShopPO/ShopPOService.cs b/Distribution/Features/ShopPO/ShopPOService.cs index 1f5fda0..537376e 100644 --- a/Distribution/Features/ShopPO/ShopPOService.cs +++ b/Distribution/Features/ShopPO/ShopPOService.cs @@ -1,4 +1,6 @@ -using Distribution.Features.Stock; +using Distribution.Features.Invoice; +using Distribution.Features.PriceList; +using Distribution.Features.Stock; using Distribution.Infra.UoW; using Distribution.Models; @@ -6,6 +8,8 @@ namespace Distribution.Features.ShopPO; public class ShopPOService( IShopPORepository repo, + IInvoiceRepository invoiceRepo, + PriceListService priceListService, IUnitOfWork uow) { private static ShopPOResponse ToResponse(Models.ShopPO p) => @@ -17,10 +21,10 @@ public class ShopPOService( i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(), p.IsActive, p.CreatedAt); - public async Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) + public async Task> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize) { - var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed); - var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize); + var total = await repo.GetCountAsync(agentId, routeId, regionId, shopId, confirmed, status); + var items = await repo.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize); return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); } @@ -29,4 +33,174 @@ public class ShopPOService( var po = await repo.GetByIdAsync(id); return po is null ? null : ToResponse(po); } + + public async Task CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default) + { + if (request.Items.Count == 0) + throw new InvalidOperationException("At least one item is required"); + + var items = new List(); + decimal total = 0; + + foreach (var line in request.Items) + { + var unitPrice = line.UnitPrice + ?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price + ?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}"); + + items.Add(new ShopPOItem + { + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + QuantityFulfilled = 0, + UnitPrice = unitPrice + }); + total += line.Quantity * unitPrice; + } + + var po = new Models.ShopPO + { + FK_StoreId = request.StoreId, + RaisedById = raisedById, + TotalAmount = total, + Status = ShopPOStatus.Pending + }; + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(po); + foreach (var item in items) item.FK_ShopPOId = po.Id; + await repo.AddItemsAsync(items); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(po.Id); + return ToResponse(saved!); + } + + public async Task UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default) + { + var po = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Shop PO not found"); + + if (po.ConfirmedAt is not null) + throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed"); + + if (request.Items.Count == 0) + throw new InvalidOperationException("At least one item is required"); + + var newItems = new List(); + decimal total = 0; + + foreach (var line in request.Items) + { + var unitPrice = line.UnitPrice + ?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price + ?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}"); + + newItems.Add(new ShopPOItem + { + FK_ShopPOId = id, + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + QuantityFulfilled = 0, + UnitPrice = unitPrice + }); + total += line.Quantity * unitPrice; + } + + po.TotalAmount = total; + + await uow.BeginAsync(ct); + try + { + await repo.ReplaceItemsAsync(id, newItems); + repo.Update(po); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(id); + return ToResponse(saved!); + } + + public async Task ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default) + { + var po = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Shop PO not found"); + + if (po.ConfirmedAt is not null) + throw new InvalidOperationException("Shop PO is already confirmed"); + + if (po.Status == ShopPOStatus.Cancelled) + throw new InvalidOperationException("Cannot confirm a cancelled Shop PO"); + + po.ConfirmedAt = DateTime.Now; + po.ConfirmedBy = confirmedBy; + + await uow.BeginAsync(ct); + try { repo.Update(po); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(po); + } + + public async Task ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default) + { + var po = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Shop PO not found"); + + if (po.ConfirmedAt is not null) + throw new InvalidOperationException("Shop PO is already confirmed"); + + if (po.Status == ShopPOStatus.Cancelled) + throw new InvalidOperationException("Cannot confirm a cancelled Shop PO"); + + po.ConfirmedAt = DateTime.Now; + po.ConfirmedBy = userId; + + var invoice = new Models.Invoice + { + FK_ShopPOId = po.Id, + Status = InvoiceStatus.Draft, + PaymentStatus = PaymentStatus.Pending, + CreatedBy = userId, + TotalAmount = po.TotalAmount + }; + + await uow.BeginAsync(ct); + try + { + await invoiceRepo.AddAsync(invoice); + po.InvoiceId = invoice.Id; + repo.Update(po); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(id); + return ToResponse(saved!); + } + + public async Task CancelAsync(Guid id, CancellationToken ct = default) + { + var po = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Shop PO not found"); + + if (po.Status == ShopPOStatus.Cancelled) + throw new InvalidOperationException("Shop PO is already cancelled"); + + if (po.Status is ShopPOStatus.PartiallyFulfilled or ShopPOStatus.Fulfilled) + throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status"); + + po.Status = ShopPOStatus.Cancelled; + + await uow.BeginAsync(ct); + try { repo.Update(po); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(po); + } }