feat: integrate notification system across PO,store,supplier claims,delivery task and ride

This commit is contained in:
2026-10-20 16:49:05 +05:30
parent d1c3d4e22f
commit a0df2a1bbf
6 changed files with 147 additions and 2 deletions
+26
View File
@@ -71,6 +71,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<ReceivedCheque> ReceivedCheques { get; set; } public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
public DbSet<SupplierPayment> SupplierPayments { get; set; } public DbSet<SupplierPayment> SupplierPayments { get; set; }
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; } public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -707,6 +709,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierPaymentGRN>() modelBuilder.Entity<SupplierPaymentGRN>()
.HasIndex(a => a.FK_GoodsReceiptId); .HasIndex(a => a.FK_GoodsReceiptId);
// --- Notifications ---
modelBuilder.Entity<Notification>()
.HasIndex(n => new { n.EntityType, n.EntityId });
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.Notification)
.WithMany(n => n.Recipients)
.HasForeignKey(r => r.FK_NotificationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.FK_UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_UserId, r.IsRead });
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder); Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder); Seed.OperationsDataSeed.Apply(modelBuilder);
@@ -1,4 +1,5 @@
using Distribution.Features.LoadingEvent; using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.ShopPO; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
IShopPORepository shopPORepo, IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo, ILoadingSheetRepository loadingSheetRepo,
StockMover mover, StockMover mover,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private const string DeliveriesLink = "/operations/deliveries";
// Route owner + managers whose scope covers the store being delivered to.
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
[
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
];
private static DeliveryTaskResponse ToResponse(DeliveryTask t) => private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id, new(t.Id,
t.FK_RideId, t.FK_RideId,
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
if (!fullyDelivered && shopPO.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskPartiallyDelivered,
DeliveryRecipients(shopPO.FK_StoreId),
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
ct);
var saved = await repo.GetByIdAsync(taskId); var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!); return ToResponse(saved!);
} }
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
if (task.ShopPO?.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskFailed,
DeliveryRecipients(task.ShopPO.FK_StoreId),
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
ct);
return ToResponse(task); return ToResponse(task);
} }
} }
+51 -1
View File
@@ -1,4 +1,6 @@
using Distribution.Features.Stock; using Distribution.Features.DeliveryRide;
using Distribution.Features.Notifications;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile; using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,9 +12,14 @@ public class RideService(
IRideRepository rideRepo, IRideRepository rideRepo,
IDriverProfileRepository driverRepo, IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo, IDriverComplianceConfigRepository complianceRepo,
IDeliveryTaskRepository taskRepo,
StockMover mover, StockMover mover,
INotificationService notifications,
ILogger<RideService> logger,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private const string RidesLink = "/operations/rides";
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
{ {
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId); var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
@@ -72,6 +79,12 @@ public class RideService(
try { rideRepo.Update(ride); await uow.CommitAsync(ct); } try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await notifications.NotifyAsync(
NotificationTypes.RideDriverAssigned,
[RecipientRule.Users(driver.FK_UserId)],
new NotificationArgs(null, "Ride", rideId, RidesLink),
ct);
var saved = await rideRepo.GetByIdAsync(rideId); var saved = await rideRepo.GetByIdAsync(rideId);
return ToResponse(saved!); return ToResponse(saved!);
} }
@@ -125,9 +138,46 @@ public class RideService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyRideCancelledAsync(ride, request, userId, ct);
return ToResponse(ride); return ToResponse(ride);
} }
// A ride has no route of its own; the people to tell are the owners and managers of the routes
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
var routeIds = tasks
.Where(t => t.ShopPO?.Store is not null)
.Select(t => t.ShopPO.Store.FK_RouteId)
.Distinct()
.ToList();
var recipients = new List<RecipientRule>();
foreach (var routeId in routeIds)
{
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
}
if (recipients.Count == 0) return;
await notifications.NotifyAsync(
NotificationTypes.RideCancelled,
recipients,
new NotificationArgs(
new { CancelType = request.TypeCode, Reason = request.Reason },
"Ride", ride.Id, RidesLink, userId),
ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
}
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId) private async Task CheckDriverComplianceAsync(Guid driverProfileId)
{ {
var driver = await driverRepo.GetByIdAsync(driverProfileId) var driver = await driverRepo.GetByIdAsync(driverProfileId)
@@ -1,4 +1,5 @@
using Distribution.Features.Invoice; using Distribution.Features.Invoice;
using Distribution.Features.Notifications;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,8 +11,17 @@ public class ShopPOService(
IShopPORepository repo, IShopPORepository repo,
IInvoiceRepository invoiceRepo, IInvoiceRepository invoiceRepo,
PriceListService priceListService, PriceListService priceListService,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
notifications.NotifyAsync(
type,
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
ct);
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) => private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber, p.Store?.StoreAddress, p.Store?.ContactNumber,
@@ -168,6 +178,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); } try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
return await ToResponseWithUserAsync(po); return await ToResponseWithUserAsync(po);
} }
@@ -204,6 +216,8 @@ public class ShopPOService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
var saved = await repo.GetByIdAsync(id); var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!); return await ToResponseWithUserAsync(saved!);
} }
@@ -231,6 +245,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); } try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
return await ToResponseWithUserAsync(po); return await ToResponseWithUserAsync(po);
} }
} }
+15 -1
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Notifications;
using Distribution.Features.Scheme; using Distribution.Features.Scheme;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
@@ -6,7 +7,7 @@ using Distribution.Models;
namespace Distribution.Features.Store; namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo) public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications)
{ {
private const int GlobalScopeRoleLevel = 50; private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20; private const int CreditApproverRoleLevel = 20;
@@ -147,6 +148,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id); var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null; if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null; if (!await HasStoreScopeAsync(id)) return null;
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
store.OutstandingBalance = request.NewBalance; store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
@@ -159,6 +161,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct); await uow.RollbackAsync(ct);
throw; throw;
} }
// Only on the crossing, so repeated updates while over the limit don't re-alert.
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
await notifications.NotifyAsync(
NotificationTypes.CreditLimitBreached,
[
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
],
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
ct);
return ToResponse(store); return ToResponse(store);
} }
@@ -1,4 +1,5 @@
using Distribution.Features.Grn; using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -9,6 +10,7 @@ public class SupplierClaimService(
ISupplierClaimRepository repo, ISupplierClaimRepository repo,
IPoRepository poRepo, IPoRepository poRepo,
IGrnRepository grnRepo, IGrnRepository grnRepo,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) => private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
@@ -149,9 +151,19 @@ public class SupplierClaimService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
return Map(claim); return Map(claim);
} }
// Tells whoever raised the claim how it was decided (never the person who decided it).
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
notifications.NotifyAsync(
type,
[RecipientRule.Users(claim.RaisedByUserId)],
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
ct);
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default) public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{ {
var claim = await repo.GetByIdAsync(id) var claim = await repo.GetByIdAsync(id)
@@ -172,6 +184,8 @@ public class SupplierClaimService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
return Map(claim); return Map(claim);
} }
} }