Phases 3 & 4: Complete data access architecture migration

Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -89,4 +89,17 @@ public class BillRepository : Repository<Bill>, IBillRepository
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
{
return await _context.Bills
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
.Include(b => b.Vendor)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Account)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.OrderBy(b => b.BillDate)
.ToListAsync();
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
/// ledger query on top of the generic <see cref="Repository{T}"/>.
/// </summary>
public class InventoryTransactionRepository : Repository<InventoryTransaction>, IInventoryTransactionRepository
{
public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<InventoryTransaction>> GetForLedgerAsync(
int? itemId,
DateTime? from,
DateTime? to,
InventoryTransactionType? type)
{
var query = _context.InventoryTransactions
.AsNoTracking()
.Include(t => t.InventoryItem)
.Include(t => t.PurchaseOrder)
.Include(t => t.Job)
.Where(t => !t.IsDeleted);
if (itemId.HasValue)
query = query.Where(t => t.InventoryItemId == itemId.Value);
if (from.HasValue)
query = query.Where(t => t.TransactionDate >= from.Value);
if (to.HasValue)
query = query.Where(t => t.TransactionDate < to.Value.AddDays(1));
if (type.HasValue)
query = query.Where(t => t.TransactionType == type.Value);
return await query
.OrderByDescending(t => t.TransactionDate)
.Take(500)
.ToListAsync();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
/// generic <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobItemCoatRepository : Repository<JobItemCoat>, IJobItemCoatRepository
{
public JobItemCoatRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadForOrderMarkingAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
.Include(c => c.Vendor)
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithInventoryAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithJobChainAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId)
{
return await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => c.Id != excludeCoatId
&& c.InventoryItemId == null
&& c.JobItem.CompanyId == companyId)
.ToListAsync();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobPhoto"/> that provides inventory-specific photo
/// lookup queries requiring multi-level ThenInclude chains that the generic
/// <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobPhotoRepository : Repository<JobPhoto>, IJobPhotoRepository
{
public JobPhotoRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName)
{
var query = _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job)
.ThenInclude(j => j!.Customer)
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(itemName) && colorName != itemName)
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(itemName));
else if (!string.IsNullOrEmpty(colorName))
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
else if (!string.IsNullOrEmpty(itemName))
query = query.Where(p => p.Tags!.ToLower().Contains(itemName));
return await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
}
/// <inheritdoc/>
public async Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId)
{
return await _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job)
.ThenInclude(j => j!.Customer)
.Include(p => p.Job)
.ThenInclude(j => j!.JobItems)
.ThenInclude(ji => ji.Coats)
.Where(p => !p.IsDeleted &&
p.Job != null &&
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == inventoryItemId)))
.OrderByDescending(p => p.UploadedDate)
.ToListAsync();
}
}
@@ -175,4 +175,16 @@ public class JobRepository : Repository<Job>, IJobRepository
.AsNoTracking()
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
{
return await _context.Jobs
.Where(j => j.Id == jobId && !j.IsDeleted)
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobTemplate"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> cannot express.
/// The base class handles all standard CRUD operations; this class adds the read queries
/// that require ThenInclude chains for items, coats, and prep services.
/// </summary>
public class JobTemplateRepository : Repository<JobTemplate>, IJobTemplateRepository
{
public JobTemplateRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobTemplate?> LoadForDetailsAsync(int id)
{
return await _context.JobTemplates
.Where(t => t.Id == id && !t.IsDeleted)
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.ThenInclude(c => c.InventoryItem)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync()
{
return await _context.JobTemplates
.Where(t => !t.IsDeleted && t.IsActive)
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.OrderBy(t => t.Name)
.ToListAsync();
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
@@ -60,4 +61,69 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<(List<NotificationLog> Items, int TotalCount)> GetPagedFilteredAsync(
int pageNumber, int pageSize,
string? searchTerm = null,
NotificationChannel? channel = null,
NotificationStatus? status = null,
NotificationType? type = null,
int? jobId = null,
string sortColumn = "SentAt",
string sortDirection = "desc")
{
var query = _dbSet
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.AsQueryable();
if (jobId.HasValue)
query = query.Where(n => n.JobId == jobId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(n =>
n.RecipientName.ToLower().Contains(s) ||
n.Recipient.ToLower().Contains(s) ||
(n.Subject != null && n.Subject.ToLower().Contains(s)) ||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(s)) ||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(s)));
}
if (channel.HasValue)
query = query.Where(n => n.Channel == channel.Value);
if (status.HasValue)
query = query.Where(n => n.Status == status.Value);
if (type.HasValue)
query = query.Where(n => n.NotificationType == type.Value);
var totalCount = await query.CountAsync();
query = (sortColumn, sortDirection) switch
{
("RecipientName", "asc") => query.OrderBy(n => n.RecipientName),
("RecipientName", _) => query.OrderByDescending(n => n.RecipientName),
("Channel", "asc") => query.OrderBy(n => n.Channel),
("Channel", _) => query.OrderByDescending(n => n.Channel),
("Status", "asc") => query.OrderBy(n => n.Status),
("Status", _) => query.OrderByDescending(n => n.Status),
("Type", "asc") => query.OrderBy(n => n.NotificationType),
("Type", _) => query.OrderByDescending(n => n.NotificationType),
(_, "asc") => query.OrderBy(n => n.SentAt),
_ => query.OrderByDescending(n => n.SentAt)
};
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, totalCount);
}
}
@@ -0,0 +1,73 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Generic repository for platform-level entities that do not inherit BaseEntity
/// (Announcement, BannedIp, DashboardTip, ReleaseNote). No global query filters apply
/// to these entities, so no IgnoreQueryFilters support is needed. All writes are staged
/// in the EF change tracker — call IUnitOfWork.CompleteAsync() to flush.
/// </summary>
public class PlainRepository<T> : IPlainRepository<T> where T : class
{
protected readonly ApplicationDbContext _context;
protected readonly DbSet<T> _dbSet;
public PlainRepository(ApplicationDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(int id)
=> await _dbSet.FindAsync(id);
public virtual async Task<IEnumerable<T>> GetAllAsync()
=> await _dbSet.ToListAsync();
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.Where(predicate).ToListAsync();
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.FirstOrDefaultAsync(predicate);
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.AnyAsync(predicate);
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null)
=> predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);
public virtual async Task<T> AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
return entity;
}
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities)
{
await _dbSet.AddRangeAsync(entities);
return entities;
}
public virtual Task UpdateAsync(T entity)
{
_dbSet.Update(entity);
return Task.CompletedTask;
}
public virtual async Task DeleteAsync(T entity)
{
_dbSet.Remove(entity);
await Task.CompletedTask;
}
public virtual async Task DeleteAsync(int id)
{
var entity = await GetByIdAsync(id);
if (entity != null)
await DeleteAsync(entity);
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
/// ledger query on top of the generic <see cref="Repository{T}"/>.
/// </summary>
public class PowderUsageLogRepository : Repository<PowderUsageLog>, IPowderUsageLogRepository
{
public PowderUsageLogRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<PowderUsageLog>> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to)
{
var query = _context.PowderUsageLogs
.AsNoTracking()
.Include(u => u.Job)
.ThenInclude(j => j!.Customer)
.Include(u => u.InventoryItem)
.Include(u => u.JobItemCoat)
.Where(u => !u.IsDeleted);
if (itemId.HasValue)
query = query.Where(u => u.InventoryItemId == itemId.Value);
if (from.HasValue)
query = query.Where(u => u.RecordedAt >= from.Value);
if (to.HasValue)
query = query.Where(u => u.RecordedAt < to.Value.AddDays(1));
return await query
.OrderByDescending(u => u.RecordedAt)
.Take(500)
.ToListAsync();
}
}
@@ -41,14 +41,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AiItemPrediction>? _aiItemPredictions;
// Powder Insights
private IRepository<PowderUsageLog>? _powderUsageLogs;
private IPowderUsageLogRepository? _powderUsageLogs;
// Core repositories
private ICustomerRepository? _customers;
private IJobRepository? _jobs;
private IRepository<JobDailyPriority>? _jobDailyPriorities;
private IRepository<JobItem>? _jobItems;
private IRepository<JobItemCoat>? _jobItemCoats;
private IJobItemCoatRepository? _jobItemCoats;
private IRepository<JobItemPrepService>? _jobItemPrepServices;
private IRepository<JobChangeHistory>? _jobChangeHistories;
private IRepository<JobPrepService>? _jobPrepServices;
@@ -59,13 +59,13 @@ public class UnitOfWork : IUnitOfWork
private IRepository<QuoteItemPrepService>? _quoteItemPrepServices;
private IRepository<QuoteChangeHistory>? _quoteChangeHistories;
private IRepository<InventoryItem>? _inventoryItems;
private IRepository<InventoryTransaction>? _inventoryTransactions;
private IInventoryTransactionRepository? _inventoryTransactions;
private IRepository<Equipment>? _equipment;
private IRepository<OvenCost>? _ovenCosts;
private IRepository<CompanyBlastSetup>? _blastSetups;
private IRepository<MaintenanceRecord>? _maintenanceRecords;
private IRepository<Vendor>? _vendors;
private IRepository<JobPhoto>? _jobPhotos;
private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes;
private IRepository<JobStatusHistory>? _jobStatusHistory;
@@ -97,13 +97,21 @@ public class UnitOfWork : IUnitOfWork
private IRepository<SubscriptionPlanConfig>? _subscriptionPlanConfigs;
// Job Templates
private IRepository<JobTemplate>? _jobTemplates;
private IJobTemplateRepository? _jobTemplates;
private IRepository<JobTemplateItem>? _jobTemplateItems;
private IRepository<JobTemplateItemCoat>? _jobTemplateItemCoats;
private IRepository<JobTemplateItemPrepService>? _jobTemplateItemPrepServices;
// Platform content
private IPlainRepository<Announcement>? _announcements;
private IPlainRepository<BannedIp>? _bannedIps;
private IPlainRepository<DashboardTip>? _dashboardTips;
private IRepository<InAppNotification>? _inAppNotifications;
private IPlainRepository<ReleaseNote>? _releaseNotes;
// Bug Reports
private IRepository<BugReport>? _bugReports;
private IRepository<BugReportAttachment>? _bugReportAttachments;
private IRepository<ContactSubmission>? _contactSubmissions;
private IRepository<ManufacturerLookupPattern>? _manufacturerLookupPatterns;
@@ -169,8 +177,8 @@ public class UnitOfWork : IUnitOfWork
// Powder Insights
/// <summary>Repository for <see cref="PowderUsageLog"/> records capturing per-coat powder consumption; used by powder-usage analytics.</summary>
public IRepository<PowderUsageLog> PowderUsageLogs =>
_powderUsageLogs ??= new Repository<PowderUsageLog>(_context);
public IPowderUsageLogRepository PowderUsageLogs =>
_powderUsageLogs ??= new PowderUsageLogRepository(_context);
// Core repositories
/// <summary>Repository for <see cref="Customer"/> records (commercial and non-commercial); tenant-filtered with soft delete.</summary>
@@ -190,8 +198,8 @@ public class UnitOfWork : IUnitOfWork
_jobItems ??= new Repository<JobItem>(_context);
/// <summary>Repository for <see cref="JobItemCoat"/> powder coat passes; tenant-filtered with soft delete.</summary>
public IRepository<JobItemCoat> JobItemCoats =>
_jobItemCoats ??= new Repository<JobItemCoat>(_context);
public IJobItemCoatRepository JobItemCoats =>
_jobItemCoats ??= new JobItemCoatRepository(_context);
public IRepository<JobItemPrepService> JobItemPrepServices =>
_jobItemPrepServices ??= new Repository<JobItemPrepService>(_context);
@@ -232,8 +240,8 @@ public class UnitOfWork : IUnitOfWork
_inventoryItems ??= new Repository<InventoryItem>(_context);
/// <summary>Repository for <see cref="InventoryTransaction"/> stock movements; tenant-filtered with soft delete.</summary>
public IRepository<InventoryTransaction> InventoryTransactions =>
_inventoryTransactions ??= new Repository<InventoryTransaction>(_context);
public IInventoryTransactionRepository InventoryTransactions =>
_inventoryTransactions ??= new InventoryTransactionRepository(_context);
/// <summary>Repository for <see cref="Equipment"/> records (ovens, sandblasters, booths); tenant-filtered with soft delete.</summary>
public IRepository<Equipment> Equipment =>
@@ -256,8 +264,8 @@ public class UnitOfWork : IUnitOfWork
_vendors ??= new Repository<Vendor>(_context);
/// <summary>Repository for <see cref="JobPhoto"/> attachments; tenant-filtered with soft delete.</summary>
public IRepository<JobPhoto> JobPhotos =>
_jobPhotos ??= new Repository<JobPhoto>(_context);
public IJobPhotoRepository JobPhotos =>
_jobPhotos ??= new JobPhotoRepository(_context);
/// <summary>Repository for <see cref="JobNote"/> free-text staff notes on jobs; tenant-filtered with soft delete.</summary>
public IRepository<JobNote> JobNotes =>
@@ -372,11 +380,35 @@ public class UnitOfWork : IUnitOfWork
public IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs =>
_subscriptionPlanConfigs ??= new Repository<SubscriptionPlanConfig>(_context);
// Platform content
/// <summary>Repository for <see cref="Announcement"/> platform-wide announcements; no tenant filter, no soft delete.</summary>
public IPlainRepository<Announcement> Announcements =>
_announcements ??= new PlainRepository<Announcement>(_context);
/// <summary>Repository for <see cref="BannedIp"/> IP ban records; no tenant filter, no soft delete.</summary>
public IPlainRepository<BannedIp> BannedIps =>
_bannedIps ??= new PlainRepository<BannedIp>(_context);
/// <summary>Repository for <see cref="DashboardTip"/> rotating tip-of-the-day entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<DashboardTip> DashboardTips =>
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
public IRepository<InAppNotification> InAppNotifications =>
_inAppNotifications ??= new Repository<InAppNotification>(_context);
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<ReleaseNote> ReleaseNotes =>
_releaseNotes ??= new PlainRepository<ReleaseNote>(_context);
// Bug Reports
/// <summary>Repository for <see cref="BugReport"/> user-submitted bug reports; tenant-filtered with soft delete.</summary>
public IRepository<BugReport> BugReports =>
_bugReports ??= new Repository<BugReport>(_context);
public IRepository<BugReportAttachment> BugReportAttachments =>
_bugReportAttachments ??= new Repository<BugReportAttachment>(_context);
// Contact Us
/// <summary>Repository for <see cref="ContactSubmission"/> contact form submissions; platform admins see all, company users see their own.</summary>
public IRepository<ContactSubmission> ContactSubmissions =>
@@ -397,8 +429,8 @@ public class UnitOfWork : IUnitOfWork
// Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IRepository<JobTemplate> JobTemplates =>
_jobTemplates ??= new Repository<JobTemplate>(_context);
public IJobTemplateRepository JobTemplates =>
_jobTemplates ??= new JobTemplateRepository(_context);
/// <summary>Repository for <see cref="JobTemplateItem"/> item definitions within a job template.</summary>
public IRepository<JobTemplateItem> JobTemplateItems =>
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="IAiUsageReportService"/> by querying <c>AiUsageLogs</c> and
/// <c>QuotePhotos</c> directly via <c>ApplicationDbContext</c>. Both tables either are
/// not BaseEntity (AiUsageLog) or require cross-tenant GROUP BY aggregations that must
/// execute in SQL; this service encapsulates those queries so the controller stays clean.
/// </summary>
public class AiUsageReportService : IAiUsageReportService
{
private readonly ApplicationDbContext _context;
public AiUsageReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<AiUsageReportData> GetReportDataAsync()
{
var now = DateTime.UtcNow;
var todayStart = now.Date;
var last7Start = todayStart.AddDays(-7);
var last30Start = todayStart.AddDays(-30);
var usageByCompany = await _context.AiUsageLogs
.GroupBy(l => l.CompanyId)
.Select(g => new AiCompanyUsage(
g.Key,
g.Count(l => l.CalledAt >= todayStart),
g.Count(l => l.CalledAt >= last7Start),
g.Count(l => l.CalledAt >= last30Start),
g.Count()))
.ToListAsync();
var featureStats = await _context.AiUsageLogs
.Where(l => l.CalledAt >= last30Start)
.GroupBy(l => new { l.CompanyId, l.Feature })
.Select(g => new AiFeatureStat(g.Key.CompanyId, g.Key.Feature, g.Count()))
.ToListAsync();
var photoCounts = await _context.QuotePhotos
.IgnoreQueryFilters()
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
.GroupBy(p => p.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
return new AiUsageReportData(usageByCompany, featureStats, photoCounts);
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Concrete implementation of <see cref="IAuditLogService"/> that writes <see cref="AuditLog"/>
/// entries via <see cref="ApplicationDbContext"/> directly. <c>AuditLog</c> does not inherit
/// from <c>BaseEntity</c> so it cannot be managed through the generic repository; this service
/// owns that write path and keeps <c>ApplicationDbContext</c> out of controller constructors.
/// </summary>
public class AuditLogService : IAuditLogService
{
private readonly ApplicationDbContext _context;
public AuditLogService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task LogAsync(AuditLog entry)
{
_context.AuditLogs.Add(entry);
await _context.SaveChangesAsync();
}
/// <inheritdoc/>
public async Task<List<AuditLog>> GetUserActivityAsync(string userId, int limit = 50)
{
return await _context.AuditLogs
.AsNoTracking()
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
.OrderByDescending(l => l.Timestamp)
.Take(limit)
.ToListAsync();
}
}
@@ -0,0 +1,180 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="ICompanyDataPurgeService"/> via bulk <c>ExecuteDeleteAsync</c> against
/// <see cref="ApplicationDbContext"/> directly. This is an intentional exception to the
/// IUnitOfWork pattern — identical to the rationale for <c>DataPurgeController</c> in the
/// documented permanent exceptions list. Each <c>ExecuteDeleteAsync</c> call commits immediately
/// at the database level so no <c>SaveChangesAsync</c> is needed for the bulk tiers.
/// </summary>
public class CompanyDataPurgeService : ICompanyDataPurgeService
{
private readonly ApplicationDbContext _context;
public CompanyDataPurgeService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> companyUserIds)
{
// ── Tier 1: Leaf children ─────────────────────────────────────────────
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// Announcement dismissals referencing the company's users or company-targeted announcements
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => companyUserIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 2: Mid-level children ────────────────────────────────────────
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 3: Top-level company entities ───────────────────────────────
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// Note: company record and users are left for the caller to handle via UserManager and UnitOfWork
}
/// <inheritdoc/>
public async Task ResetBusinessDataAsync(int companyId)
{
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
await _context.JobTemplateItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobTemplateItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.GiftCertificateRedemptions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CreditMemoApplications.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenBatchItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
if (announcementIds.Count > 0)
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 1: Children ──────────────────────────────────────────────────
await _context.JobTemplateItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobTimeEntries.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ReworkRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.QuotePhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Deposits.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.GiftCertificates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 2: Top-level business entities ──────────────────────────────
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.JobTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PurchaseOrders.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
// Clear QuickBooks migration wizard progress (tracked update, not bulk delete)
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == companyId);
if (prefs?.QbMigrationStateJson != null)
{
prefs.QbMigrationStateJson = null;
prefs.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="ICompanyListService"/> using <see cref="ApplicationDbContext"/> directly.
/// Queries require <c>IgnoreQueryFilters()</c> (to bypass the tenant filter and see all companies),
/// dynamic sort expressions, and cross-entity GROUP BY aggregations — all of which are beyond the
/// generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// </summary>
public class CompanyListService : ICompanyListService
{
private readonly ApplicationDbContext _context;
public CompanyListService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
{
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(c =>
c.CompanyName.ToLower().Contains(s) ||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
}
query = (sortColumn, sortDirection == "asc") switch
{
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
("Status", true) => query.OrderBy(c => c.IsActive),
("Status", false) => query.OrderByDescending(c => c.IsActive),
("Created", true) => query.OrderBy(c => c.CreatedAt),
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
_ => query.OrderBy(c => c.CompanyName)
};
var totalCount = await query.CountAsync();
var companies = await query
.Include(c => c.Users)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (companies, totalCount);
}
/// <inheritdoc/>
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
{
var jobCounts = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var quoteCounts = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted)
.GroupBy(q => q.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var customerCounts = await _context.Customers
.IgnoreQueryFilters()
.Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var wizardRaw = await _context.CompanyPreferences
.IgnoreQueryFilters()
.Where(p => companyIds.Contains(p.CompanyId) && p.SetupWizardCompleted)
.Select(p => new { p.CompanyId, p.SetupWizardCompletedAt, p.SetupWizardCompletedByName })
.ToListAsync();
var wizardInfo = wizardRaw.ToDictionary(
x => x.CompanyId,
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
}
}
@@ -0,0 +1,225 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// </summary>
public class DashboardReadService : IDashboardReadService
{
private static readonly string[] CompletedStatusCodes =
[
"COMPLETED",
"READY_FOR_PICKUP",
"DELIVERED",
"CANCELLED"
];
private readonly ApplicationDbContext _context;
public DashboardReadService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
{
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var tomorrow = today.AddDays(1);
var lookAheadDate = today.AddDays(7);
var last30Days = today.AddDays(-30);
var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
// All active jobs (for today/overdue/in-progress panels)
var activeJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.ToListAsync();
// Monthly revenue — sum completed jobs updated in current month
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
// Today's appointments (non-cancelled)
var todaysAppointments = await _context.Appointments
.AsNoTracking()
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.OrderBy(a => a.ScheduledStartTime)
.ToListAsync();
// Upcoming/overdue maintenance
var upcomingMaintenance = await _context.MaintenanceRecords
.AsNoTracking()
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
// Pending quotes (SENT status)
var pendingQuotes = await _context.Quotes
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.ToListAsync();
// Open invoices (for AR aging + overdue list)
var openInvoices = await _context.Invoices
.AsNoTracking()
.Include(i => i.Customer)
.Where(i => openInvoiceStatuses.Contains(i.Status))
.ToListAsync();
// Invoiced this month
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
// Collected this month
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
// Recent payments with Invoice → Customer
var recentPayments = await _context.Payments
.AsNoTracking()
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync();
// Recent quotes (last 30 days)
var recentQuotes = await _context.Quotes
.AsNoTracking()
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync();
// Recent jobs (last 30 days)
var recentJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync();
// Jobs needing powder (not yet ordered, insufficient stock)
var jobsNeedingPowder = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
// Jobs with powder already ordered but not yet received
var jobsWithOrderedPowder = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
// Bills due (open/partial, balance remaining)
var billsDue = await _context.Bills
.AsNoTracking()
.Include(b => b.Vendor)
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
&& b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
// Random tip of the day
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
return new DashboardIndexData(
ActiveJobs: activeJobs,
MonthlyRevenue: monthlyRevenue,
TodaysAppointments: todaysAppointments,
UpcomingMaintenance: upcomingMaintenance,
PendingQuotes: pendingQuotes,
OpenInvoices: openInvoices,
InvoicedThisMonth: invoicedThisMonth,
CollectedThisMonth: collectedThisMonth,
RecentPayments: recentPayments,
RecentQuotes: recentQuotes,
RecentJobs: recentJobs,
JobsNeedingPowder: jobsNeedingPowder,
JobsWithOrderedPowder: jobsWithOrderedPowder,
BillsDue: billsDue,
TipOfTheDay: tipOfTheDay
);
}
/// <inheritdoc/>
public async Task<int> GetTotalUserCountAsync()
{
return await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
}
}
@@ -1,14 +1,18 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements financial aggregate reports using direct DbContext access with AsNoTracking.
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
/// converted during Phase 2/3 of the data-access architecture migration.
/// Implements financial aggregate reports (P&amp;L, Balance Sheet, AR Aging, Sales &amp; Income)
/// using direct DbContext access with AsNoTracking. Migrated from inline queries in
/// ReportsController as part of Phase 2 of the data-access architecture migration.
/// The four report types each have matching PDF export paths in the controller that
/// share the same data by calling these methods, eliminating the previous duplication.
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class FinancialReportService : IFinancialReportService
@@ -21,19 +25,415 @@ public class FinancialReportService : IFinancialReportService
}
/// <inheritdoc/>
/// <remarks>Implemented — migrated from <c>ReportsController.ProfitAndLoss</c> in Phase 2.</remarks>
public Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
=> throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2.");
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
// Revenue: InvoiceItems posted to revenue accounts
var revenueByAccount = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
.ToListAsync();
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var revenueAccounts = await _context.Accounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var revenueLines = revenueByAccount
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
.Select(r => new FinancialReportLine
{
AccountId = r.AccountId,
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
AccountName = revenueAccounts[r.AccountId].Name,
Amount = r.Amount
})
.OrderBy(l => l.AccountNumber)
.ToList();
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
// COGS & Expenses: direct Expenses + BillLineItems merged per account
var directByAccount = await _context.Expenses
.Where(e => e.Date >= from && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
var billLinesByAccount = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
.ToListAsync();
var expenseAmounts = new Dictionary<int, decimal>();
foreach (var e in directByAccount)
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
foreach (var b in billLinesByAccount)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
var expAccounts = await _context.Accounts
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>();
var expenseLines = new List<FinancialReportLine>();
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
{
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
else expenseLines.Add(line);
}
return new ProfitAndLossDto
{
From = from,
To = to,
CompanyName = companyName,
RevenueLines = revenueLines,
TotalRevenue = revenueLines.Sum(l => l.Amount),
CogsLines = cogsLines,
TotalCogs = cogsLines.Sum(l => l.Amount),
ExpenseLines = expenseLines,
TotalExpenses = expenseLines.Sum(l => l.Amount),
};
}
/// <inheritdoc/>
public Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
=> throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2.");
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
{
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
var arDebits = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
.SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// Retained earnings = net P&L from inception through asOf
var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeCogs = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
var accounts = await _context.Accounts
.Where(a => a.IsActive)
.OrderBy(a => a.AccountNumber)
.ToListAsync();
// Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance.
decimal ComputeBalance(Account a)
{
bool normalDebit = a.AccountType == AccountType.Asset;
decimal debits = 0, credits = 0;
if (a.AccountSubType == AccountSubType.AccountsReceivable)
{
debits = arDebits; credits = arCredits;
}
else if (a.AccountSubType == AccountSubType.AccountsPayable)
{
credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id);
}
else
{
debits += depositsByAcct.GetValueOrDefault(a.Id);
credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id);
}
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
? a.OpeningBalance : 0;
decimal net = normalDebit ? debits - credits : credits - debits;
return opening + net;
}
FinancialReportLine ToLine(Account a) => new()
{
AccountId = a.Id,
AccountNumber = a.AccountNumber,
AccountName = a.Name,
Amount = ComputeBalance(a)
};
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
var equityLines = equityAccts.Select(ToLine).ToList();
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
return new BalanceSheetDto
{
AsOf = asOf,
CompanyName = companyName,
CurrentAssets = currentAssets,
FixedAssets = fixedAssets,
OtherAssets = otherAssets,
TotalAssets = totalAssets,
CurrentLiabilities = currentLiabilities,
LongTermLiabilities = longTermLiabilities,
TotalLiabilities = totalLiabilities,
EquityLines = equityLines,
RetainedEarnings = retainedEarnings,
TotalEquity = totalEquity,
};
}
/// <inheritdoc/>
public Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
=> throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2.");
public async Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
{
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid
&& i.InvoiceDate <= asOfEnd
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
.OrderBy(i => i.Customer!.CompanyName)
.ThenBy(i => i.DueDate)
.ToListAsync();
static string AgingBucket(int d) => d switch
{
<= 0 => "current",
<= 30 => "1-30",
<= 60 => "31-60",
<= 90 => "61-90",
_ => "90+"
};
var customerDtos = new List<ArAgingCustomerDto>();
foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }))
{
var customerName = grp.Key.IsCommercial
? grp.Key.CompanyName
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
foreach (var inv in grp)
{
var balance = inv.BalanceDue;
var daysOverdue = inv.DueDate.HasValue ? (int)(asOf - inv.DueDate.Value.Date).TotalDays : 0;
custDto.Invoices.Add(new ArAgingInvoiceDto
{
InvoiceId = inv.Id,
InvoiceNumber = inv.InvoiceNumber,
InvoiceDate = inv.InvoiceDate,
DueDate = inv.DueDate,
BalanceDue = balance,
DaysOverdue = daysOverdue
});
switch (AgingBucket(daysOverdue))
{
case "current": custDto.TotalCurrent += balance; break;
case "1-30": custDto.Total1to30 += balance; break;
case "31-60": custDto.Total31to60 += balance; break;
case "61-90": custDto.Total61to90 += balance; break;
default: custDto.TotalOver90 += balance; break;
}
}
customerDtos.Add(custDto);
}
var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList();
return new ArAgingReportDto
{
AsOf = asOf,
CompanyName = companyName,
Customers = sorted,
TotalCurrent = sorted.Sum(c => c.TotalCurrent),
Total1to30 = sorted.Sum(c => c.Total1to30),
Total31to60 = sorted.Sum(c => c.Total31to60),
Total61to90 = sorted.Sum(c => c.Total61to90),
TotalOver90 = sorted.Sum(c => c.TotalOver90),
};
}
/// <inheritdoc/>
public Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
=> throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2.");
public async Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
{
var toEnd = to.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId);
var invoices = await _context.Invoices
.Include(i => i.Customer)
.Include(i => i.Payments)
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate)
.ToListAsync();
var collectedInPeriod = await _context.Payments
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var byCustomer = invoices
.GroupBy(i => new
{
i.CustomerId,
Name = i.Customer!.IsCommercial
? i.Customer.CompanyName
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
})
.Select(g => new SalesByCustomerDto
{
CustomerId = g.Key.CustomerId,
CustomerName = g.Key.Name,
InvoiceCount = g.Count(),
TotalInvoiced = g.Sum(i => i.Total),
TotalPaid = g.Sum(i => i.AmountPaid),
BalanceDue = g.Sum(i => i.BalanceDue),
})
.OrderByDescending(c => c.TotalInvoiced)
.ToList();
var byMonth = invoices
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new SalesByMonthDto
{
Year = g.Key.Year,
Month = g.Key.Month,
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
TotalInvoiced = g.Sum(i => i.Total),
TotalCollected = g.Sum(i => i.AmountPaid),
InvoiceCount = g.Count(),
})
.OrderBy(m => m.Year).ThenBy(m => m.Month)
.ToList();
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
{
InvoiceId = i.Id,
InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer!.IsCommercial
? i.Customer.CompanyName
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
InvoiceDate = i.InvoiceDate,
DueDate = i.DueDate,
Status = i.Status.ToString(),
SubTotal = i.SubTotal,
TaxAmount = i.TaxAmount,
Total = i.Total,
AmountPaid = i.AmountPaid,
BalanceDue = i.BalanceDue,
}).ToList();
return new SalesIncomeReportDto
{
From = from,
To = to,
CompanyName = companyName,
TotalInvoiced = invoices.Sum(i => i.Total),
TotalCollected = collectedInPeriod,
TotalTax = invoices.Sum(i => i.TaxAmount),
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
InvoiceCount = invoices.Count,
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
ByCustomer = byCustomer,
ByMonth = byMonth,
Invoices = invoiceLines,
};
}
/// <summary>
/// Looks up the company name by ID for report headers and AI prompt injection.
/// Falls back to "Your Company" if the record is not found.
/// </summary>
private async Task<string> GetCompanyNameAsync(int companyId)
{
if (companyId <= 0) return "Your Company";
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
return company?.CompanyName ?? "Your Company";
}
}
@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
@@ -19,10 +22,124 @@ public class OperationalReportService : IOperationalReportService
}
/// <inheritdoc/>
public Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months)
=> throw new NotImplementedException("Migrate from ReportsController.JobCycleTime — Phase 2.");
public async Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months)
{
var completedCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && completedCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue)
.AsNoTracking()
.ToListAsync();
var history = await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.AsNoTracking()
.ToListAsync();
var historyByJob = history
.GroupBy(h => h.JobId)
.ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
foreach (var job in completedJobs)
{
if (!historyByJob.TryGetValue(job.Id, out var jobHistory) || !jobHistory.Any()) continue;
var prevDate = job.CreatedAt;
foreach (var entry in jobHistory)
{
var fc = entry.FromStatus?.StatusCode;
var fn = entry.FromStatus?.DisplayName;
if (fc == null) { prevDate = entry.ChangedDate; continue; }
var d = (entry.ChangedDate - prevDate).TotalDays;
if (d >= 0 && d <= 365)
{
if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List<double>());
statusTimings[fc].Days.Add(d);
}
prevDate = entry.ChangedDate;
}
var last = jobHistory.Last();
var tc = last.ToStatus?.StatusCode;
var tn = last.ToStatus?.DisplayName;
if (tc != null)
{
var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays;
if (d >= 0 && d <= 365)
{
if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List<double>());
statusTimings[tc].Days.Add(d);
}
}
}
var rows = statusTimings
.Where(kv => kv.Value.Days.Any())
.Select(kv => new JobCycleTimeRow(kv.Value.DisplayName, Math.Round(kv.Value.Days.Average(), 1), kv.Value.Days.Count))
.ToList();
return new JobCycleTimeReport(rows, months);
}
/// <inheritdoc/>
public Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months)
=> throw new NotImplementedException("Migrate from ReportsController.PowderUsage — Phase 2.");
public async Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months)
{
var startDate = DateTime.UtcNow.AddMonths(-months);
var transactions = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => !t.IsDeleted
&& t.TransactionType == InventoryTransactionType.JobUsage
&& t.TransactionDate >= startDate)
.AsNoTracking()
.ToListAsync();
var rows = transactions
.Where(t => t.InventoryItem != null)
.GroupBy(t => t.InventoryItemId)
.Select(g => new PowderUsageRow(
ColorName: g.First().InventoryItem!.ColorName ?? g.First().InventoryItem!.Name,
VendorName: g.First().InventoryItem!.Manufacturer ?? string.Empty,
TotalLbs: g.Sum(t => Math.Abs(t.Quantity)),
TotalCost: g.Sum(t => Math.Abs(t.TotalCost))))
.OrderByDescending(r => r.TotalLbs)
.ToList();
return new PowderUsageReport(rows, months);
}
/// <inheritdoc/>
public async Task<List<Bill>> GetActiveBillsAsync()
{
return await _context.Bills
.Include(b => b.Vendor)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<Expense>> GetAllExpensesAsync()
{
return await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync()
{
return await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.AsNoTracking()
.ToListAsync();
}
}