diff --git a/Distribution/Data/Seed/MasterDataSeed.cs b/Distribution/Data/Seed/MasterDataSeed.cs index 5138115..53d8a40 100644 --- a/Distribution/Data/Seed/MasterDataSeed.cs +++ b/Distribution/Data/Seed/MasterDataSeed.cs @@ -96,12 +96,12 @@ public static class MasterDataSeed ); modelBuilder.Entity().HasData( - new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true }, - new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true }, - new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }, - new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }, - new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true }, - new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true } + new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true }, + new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true }, + new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true }, + new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }, + new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", CreatedAt = SeedDate, IsActive = true }, + new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true } ); modelBuilder.Entity().HasData( diff --git a/Distribution/Data/Seed/OperationsExtrasSeed.cs b/Distribution/Data/Seed/OperationsExtrasSeed.cs index db3f318..d643761 100644 --- a/Distribution/Data/Seed/OperationsExtrasSeed.cs +++ b/Distribution/Data/Seed/OperationsExtrasSeed.cs @@ -27,8 +27,6 @@ public static class OperationsExtrasSeed private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002"); private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003"); - private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001"); - private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002"); private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001"); @@ -83,12 +81,6 @@ public static class OperationsExtrasSeed new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId }, new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId } ); - - modelBuilder.Entity().HasData( - new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true }, - new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true } - ); - modelBuilder.Entity().HasData( new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId } ); diff --git a/Distribution/Features/ShopPO/IShopPORepository.cs b/Distribution/Features/ShopPO/IShopPORepository.cs index 3bf81a8..57e893f 100644 --- a/Distribution/Features/ShopPO/IShopPORepository.cs +++ b/Distribution/Features/ShopPO/IShopPORepository.cs @@ -10,6 +10,7 @@ public interface IShopPORepository Task> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId); Task> GetActiveItemsByShopPOIdAsync(Guid shopPOId); Task> GetByStoreIdAsync(Guid storeId); + Task> GetUserInfoAsync(IEnumerable userIds); Task AddAsync(Models.ShopPO entity); void Update(Models.ShopPO entity); void Remove(Models.ShopPO entity); diff --git a/Distribution/Features/ShopPO/ShopPOController.cs b/Distribution/Features/ShopPO/ShopPOController.cs index f8f5fe9..45e643e 100644 --- a/Distribution/Features/ShopPO/ShopPOController.cs +++ b/Distribution/Features/ShopPO/ShopPOController.cs @@ -90,11 +90,15 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current } [HttpPost("{id:guid}/cancel")] - public async Task Cancel(Guid id, CancellationToken ct) + public async Task Cancel(Guid id, CancelShopPORequest request, CancellationToken ct) { + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.CancelledByUserId; + if (userId is null) return BadRequest(new { error = "CancelledByUserId is required while auth is unavailable" }); try { - var result = await service.CancelAsync(id, ct); + var result = await service.CancelAsync(id, userId.Value, request.Reason, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/ShopPO/ShopPODTOs.cs b/Distribution/Features/ShopPO/ShopPODTOs.cs index 5d50e6d..63f3a05 100644 --- a/Distribution/Features/ShopPO/ShopPODTOs.cs +++ b/Distribution/Features/ShopPO/ShopPODTOs.cs @@ -6,6 +6,7 @@ public record CreateShopPORequest(Guid StoreId, List Items, G public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); public record UpdateShopPOItemsRequest(List Items); public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null); +public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null); public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive); @@ -22,4 +23,13 @@ public record ShopPOResponse( DateTime? FulfilledAt, List Items, bool IsActive, - DateTime CreatedAt); + DateTime CreatedAt, + string? RaisedByName = null, + string? RaisedByRole = null, + string? ConfirmedByName = null, + string? ConfirmedByRole = null, + Guid? CancelledBy = null, + DateTime? CancelledAt = null, + string? CancelReason = null, + string? CancelledByName = null, + string? CancelledByRole = null); diff --git a/Distribution/Features/ShopPO/ShopPORepository.cs b/Distribution/Features/ShopPO/ShopPORepository.cs index 271793c..1643c5f 100644 --- a/Distribution/Features/ShopPO/ShopPORepository.cs +++ b/Distribution/Features/ShopPO/ShopPORepository.cs @@ -64,6 +64,16 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository .Where(p => p.FK_StoreId == storeId && p.IsActive) .ToListAsync(); + public async Task> GetUserInfoAsync(IEnumerable userIds) + { + var ids = userIds.Distinct().ToList(); + var users = await context.Users + .Where(u => ids.Contains(u.Id)) + .Select(u => new { u.Id, u.FullName, RoleName = u.Role.Name }) + .ToListAsync(); + return users.ToDictionary(u => u.Id, u => (u.FullName, u.RoleName)); + } + public async Task AddAsync(Models.ShopPO entity) => await context.ShopPOs.AddAsync(entity); diff --git a/Distribution/Features/ShopPO/ShopPOService.cs b/Distribution/Features/ShopPO/ShopPOService.cs index 537376e..aa07213 100644 --- a/Distribution/Features/ShopPO/ShopPOService.cs +++ b/Distribution/Features/ShopPO/ShopPOService.cs @@ -12,26 +12,46 @@ public class ShopPOService( PriceListService priceListService, IUnitOfWork uow) { - private static ShopPOResponse ToResponse(Models.ShopPO p) => + private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary? users = null) => new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status, p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt, p.Items.Where(i => i.IsActive).Select(i => new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(), - p.IsActive, p.CreatedAt); + p.IsActive, p.CreatedAt, + users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null, + users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null, + users != null && p.ConfirmedBy is Guid cb && users.TryGetValue(cb, out var c) ? c.Name : null, + users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null, + p.CancelledBy, p.CancelledAt, p.CancelReason, + users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null, + users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null); + + private static IEnumerable UserIds(Models.ShopPO p) + { + yield return p.RaisedById; + if (p.ConfirmedBy is Guid cb) yield return cb; + if (p.CancelledBy is Guid xb) yield return xb; + } + + private async Task ToResponseWithUserAsync(Models.ShopPO p) => + ToResponse(p, await repo.GetUserInfoAsync(UserIds(p))); 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, 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()); + var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds)); + return new PagedResponse(page, pageSize, total, items.Select(p => ToResponse(p, users)).ToList()); } public async Task GetByIdAsync(Guid id) { var po = await repo.GetByIdAsync(id); - return po is null ? null : ToResponse(po); + if (po is null) return null; + var users = await repo.GetUserInfoAsync(UserIds(po)); + return ToResponse(po, users); } public async Task CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default) @@ -77,7 +97,7 @@ public class ShopPOService( catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(po.Id); - return ToResponse(saved!); + return await ToResponseWithUserAsync(saved!); } public async Task UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default) @@ -85,6 +105,9 @@ public class ShopPOService( var po = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Shop PO not found"); + if (po.Status == ShopPOStatus.Cancelled) + throw new InvalidOperationException("Cannot edit a cancelled Shop PO"); + if (po.ConfirmedAt is not null) throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed"); @@ -123,7 +146,7 @@ public class ShopPOService( catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(id); - return ToResponse(saved!); + return await ToResponseWithUserAsync(saved!); } public async Task ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default) @@ -144,7 +167,7 @@ public class ShopPOService( try { repo.Update(po); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return ToResponse(po); + return await ToResponseWithUserAsync(po); } public async Task ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default) @@ -181,11 +204,14 @@ public class ShopPOService( catch { await uow.RollbackAsync(ct); throw; } var saved = await repo.GetByIdAsync(id); - return ToResponse(saved!); + return await ToResponseWithUserAsync(saved!); } - public async Task CancelAsync(Guid id, CancellationToken ct = default) + public async Task CancelAsync(Guid id, Guid cancelledBy, string reason, CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(reason)) + throw new InvalidOperationException("A cancellation reason is required"); + var po = await repo.GetByIdAsync(id) ?? throw new KeyNotFoundException("Shop PO not found"); @@ -196,11 +222,14 @@ public class ShopPOService( throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status"); po.Status = ShopPOStatus.Cancelled; + po.CancelledAt = DateTime.Now; + po.CancelledBy = cancelledBy; + po.CancelReason = reason.Trim(); await uow.BeginAsync(ct); try { repo.Update(po); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return ToResponse(po); + return await ToResponseWithUserAsync(po); } }