Multi-tenancy hardening: explicit companyId on all typed repository methods
All typed repository methods that previously relied solely on global query filters now require an explicit companyId parameter, providing defense-in- depth so IgnoreQueryFilters calls cannot leak cross-tenant data. - IBillRepository/BillRepository: GetForIndexAsync, LoadForViewAsync, LoadForEditAsync, GetLastBillNumberAsync, GetLastPaymentNumberAsync, GetForDateRangeAsync all scoped to companyId - IJobRepository/JobRepository: LoadForDetailsAsync, LoadForEditAsync, LoadForStatusChangeAsync, GetChangeHistoryAsync, LoadForTemplateSnapshotAsync, GetReworkJobCountAsync - IQuoteRepository/QuoteRepository: LoadForDetailsAsync, GetChangeHistoryAsync, GetItemsWithCoatsAsync - IInvoiceRepository/InvoiceRepository: LoadForViewAsync - ICustomerRepository/CustomerRepository: LoadForDetailsAsync - INotificationLogRepository/NotificationLogRepository: all 6 FK methods - BillsController: ITenantContext injected, all call sites updated - AccountingExportController, InvoicesController, JobsController, JobTemplatesController, QuotesController: call sites updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,37 +13,39 @@ public interface IBillRepository : IRepository<Bill>
|
|||||||
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
|
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
|
||||||
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
|
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Bill?> LoadForViewAsync(int id);
|
Task<Bill?> LoadForViewAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single bill with only its line items for the Edit form. Excludes payment
|
/// Loads a single bill with only its line items for the Edit form. Excludes payment
|
||||||
/// navigations since those are read-only after the bill is opened.
|
/// navigations since those are read-only after the bill is opened.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Bill?> LoadForEditAsync(int id);
|
Task<Bill?> LoadForEditAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
|
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
|
||||||
/// Includes Vendor so the list row can display vendor name without a second round trip.
|
/// Includes Vendor so the list row can display vendor name without a second round trip.
|
||||||
/// LineItems are included for the search-in-description condition only.
|
/// LineItems are included for the search-in-description condition only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
|
Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last bill number with the given prefix (including soft-deleted records) for
|
/// Returns the last bill number with the given prefix (including soft-deleted records) for
|
||||||
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
|
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
|
||||||
|
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLastBillNumberAsync(string prefix);
|
Task<string?> GetLastBillNumberAsync(int companyId, string prefix);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last payment number with the given prefix (including soft-deleted records)
|
/// Returns the last payment number with the given prefix (including soft-deleted records)
|
||||||
/// for sequential payment reference generation.
|
/// for sequential payment reference generation.
|
||||||
|
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLastPaymentNumberAsync(string prefix);
|
Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
|
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
|
||||||
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
|
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
|
||||||
/// Used by the accounting data export to produce QuickBooks IIF / CSV files.
|
/// Scoped to <paramref name="companyId"/>. Used by the accounting data export.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end);
|
Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface ICustomerRepository : IRepository<Customer>
|
|||||||
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
|
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
|
||||||
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
|
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Customer?> LoadForDetailsAsync(int id);
|
Task<Customer?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds a customer by email address within the current tenant. Used for duplicate-email
|
/// Finds a customer by email address within the current tenant. Used for duplicate-email
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public interface IInvoiceRepository : IRepository<Invoice>
|
|||||||
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
|
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
|
||||||
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
|
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Invoice?> LoadForViewAsync(int id);
|
Task<Invoice?> LoadForViewAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the invoice linked to a job, or null if none exists. Pass
|
/// Returns the invoice linked to a job, or null if none exists. Pass
|
||||||
|
|||||||
@@ -22,26 +22,26 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
|
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
|
||||||
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
|
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForDetailsAsync(int id);
|
Task<Job?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single job with the include chain required by the Edit form: same as
|
/// Loads a single job with the include chain required by the Edit form: same as
|
||||||
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
|
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
|
||||||
/// with tracking enabled so changes can be saved.
|
/// with tracking enabled so changes can be saved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForEditAsync(int id);
|
Task<Job?> LoadForEditAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
|
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
|
||||||
/// Includes only JobStatus. Returns null if not found or soft-deleted.
|
/// Includes only JobStatus. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForStatusChangeAsync(int id);
|
Task<Job?> LoadForStatusChangeAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
|
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
|
||||||
/// loaded. Used by the Details view changelog tab.
|
/// loaded. Used by the Details view changelog tab.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
|
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
|
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
|
||||||
@@ -84,7 +84,7 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
|
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
|
||||||
/// Returns null if not found or soft-deleted.
|
/// Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
|
Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
||||||
@@ -96,6 +96,7 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// Scoped to <paramref name="companyId"/> to prevent cross-tenant count collisions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> GetReworkJobCountAsync(int originalJobId);
|
Task<int> GetReworkJobCountAsync(int originalJobId, int companyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ namespace PowderCoating.Core.Interfaces.Repositories;
|
|||||||
public interface INotificationLogRepository : IRepository<NotificationLog>
|
public interface INotificationLogRepository : IRepository<NotificationLog>
|
||||||
{
|
{
|
||||||
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
|
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
|
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
|
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
|
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
|
Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
|
Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
|
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
|
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
|
||||||
/// Returns null if not found or soft-deleted.
|
/// Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Quote?> LoadForDetailsAsync(int id);
|
Task<Quote?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
|
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
|
||||||
@@ -29,7 +29,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
|
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
|
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
|
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
|
||||||
@@ -43,7 +43,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
|
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
|
||||||
/// because it skips the parent-quote navigations that callers already have.
|
/// because it skips the parent-quote navigations that callers already have.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
|
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
|
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
public BillRepository(ApplicationDbContext context) : base(context) { }
|
public BillRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Bill?> LoadForViewAsync(int id)
|
public async Task<Bill?> LoadForViewAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => b.Id == id && !b.IsDeleted)
|
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.APAccount)
|
.Include(b => b.APAccount)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
@@ -31,21 +31,21 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Bill?> LoadForEditAsync(int id)
|
public async Task<Bill?> LoadForEditAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => b.Id == id && !b.IsDeleted)
|
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount)
|
public async Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount)
|
||||||
{
|
{
|
||||||
var query = _context.Bills
|
var query = _context.Bills
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.Where(b => !b.IsDeleted);
|
.Where(b => b.CompanyId == companyId && !b.IsDeleted);
|
||||||
|
|
||||||
if (statusFilter == "Unpaid")
|
if (statusFilter == "Unpaid")
|
||||||
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
||||||
@@ -69,32 +69,32 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<string?> GetLastBillNumberAsync(string prefix)
|
public async Task<string?> GetLastBillNumberAsync(int companyId, string prefix)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
.Where(b => b.CompanyId == companyId && b.BillNumber.StartsWith(prefix))
|
||||||
.OrderByDescending(b => b.BillNumber)
|
.OrderByDescending(b => b.BillNumber)
|
||||||
.Select(b => b.BillNumber)
|
.Select(b => b.BillNumber)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
|
public async Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix)
|
||||||
{
|
{
|
||||||
return await _context.BillPayments
|
return await _context.BillPayments
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => p.PaymentNumber.StartsWith(prefix))
|
.Where(p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(prefix))
|
||||||
.OrderByDescending(p => p.PaymentNumber)
|
.OrderByDescending(p => p.PaymentNumber)
|
||||||
.Select(p => p.PaymentNumber)
|
.Select(p => p.PaymentNumber)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
|
public async Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
.Where(b => b.CompanyId == companyId && !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.ThenInclude(li => li.Account)
|
.ThenInclude(li => li.Account)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ public class CustomerRepository : Repository<Customer>, ICustomerRepository
|
|||||||
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Customer?> LoadForDetailsAsync(int id)
|
public async Task<Customer?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Customers
|
return await _context.Customers
|
||||||
.Where(c => c.Id == id && !c.IsDeleted)
|
.Where(c => c.Id == id && c.CompanyId == companyId && !c.IsDeleted)
|
||||||
.Include(c => c.PricingTier)
|
.Include(c => c.PricingTier)
|
||||||
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
|
|||||||
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Invoice?> LoadForViewAsync(int id)
|
public async Task<Invoice?> LoadForViewAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Set<Invoice>()
|
return await _context.Set<Invoice>()
|
||||||
.Where(i => i.Id == id && !i.IsDeleted)
|
.Where(i => i.Id == id && i.CompanyId == companyId && !i.IsDeleted)
|
||||||
.Include(i => i.Customer)
|
.Include(i => i.Customer)
|
||||||
.Include(i => i.Job)
|
.Include(i => i.Job)
|
||||||
.Include(i => i.PreparedBy)
|
.Include(i => i.PreparedBy)
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForDetailsAsync(int id)
|
public async Task<Job?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
||||||
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
||||||
// (split query behavior), keeping result set size manageable.
|
// (split query behavior), keeping result set size manageable.
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == id && !j.IsDeleted)
|
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.Customer)
|
.Include(j => j.Customer)
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.Include(j => j.JobPriority)
|
.Include(j => j.JobPriority)
|
||||||
@@ -62,10 +62,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForEditAsync(int id)
|
public async Task<Job?> LoadForEditAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == id && !j.IsDeleted)
|
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.Customer)
|
.Include(j => j.Customer)
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.Include(j => j.JobPriority)
|
.Include(j => j.JobPriority)
|
||||||
@@ -86,18 +86,18 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForStatusChangeAsync(int id)
|
public async Task<Job?> LoadForStatusChangeAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
|
.FirstOrDefaultAsync(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
|
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.JobChangeHistories
|
return await _context.JobChangeHistories
|
||||||
.Where(h => h.JobId == jobId && !h.IsDeleted)
|
.Where(h => h.JobId == jobId && h.CompanyId == companyId && !h.IsDeleted)
|
||||||
.Include(h => h.ChangedBy)
|
.Include(h => h.ChangedBy)
|
||||||
.OrderByDescending(h => h.ChangedAt)
|
.OrderByDescending(h => h.ChangedAt)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -176,10 +176,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
|
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == jobId && !j.IsDeleted)
|
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
@@ -188,11 +188,11 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
public async Task<int> GetReworkJobCountAsync(int originalJobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.CountAsync(j => j.OriginalJobId == originalJobId);
|
.CountAsync(j => j.OriginalJobId == originalJobId && j.CompanyId == companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -15,50 +15,50 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
|
|||||||
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
|
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.InvoiceId == invoiceId)
|
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
|
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.InvoiceId == invoiceId)
|
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
|
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.QuoteId == quoteId)
|
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
|
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.QuoteId == quoteId)
|
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
|
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.JobId == jobId)
|
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
|
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.JobId == jobId)
|
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Quote?> LoadForDetailsAsync(int id)
|
public async Task<Quote?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
var quote = await _context.Quotes
|
var quote = await _context.Quotes
|
||||||
.Where(q => q.Id == id && !q.IsDeleted)
|
.Where(q => q.Id == id && q.CompanyId == companyId && !q.IsDeleted)
|
||||||
.Include(q => q.Customer)
|
.Include(q => q.Customer)
|
||||||
.Include(q => q.PreparedBy)
|
.Include(q => q.PreparedBy)
|
||||||
.Include(q => q.QuoteStatus)
|
.Include(q => q.QuoteStatus)
|
||||||
@@ -32,7 +32,7 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
// QuoteItems with nested coats and prep services loaded separately to avoid
|
// QuoteItems with nested coats and prep services loaded separately to avoid
|
||||||
// cartesian explosion from multiple collection includes in a single query.
|
// cartesian explosion from multiple collection includes in a single query.
|
||||||
quote.QuoteItems = await _context.QuoteItems
|
quote.QuoteItems = await _context.QuoteItems
|
||||||
.Where(qi => qi.QuoteId == id && !qi.IsDeleted)
|
.Where(qi => qi.QuoteId == id && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
.ThenInclude(c => c.InventoryItem)
|
.ThenInclude(c => c.InventoryItem)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
@@ -58,10 +58,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
|
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.QuoteChangeHistories
|
return await _context.QuoteChangeHistories
|
||||||
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
|
.Where(h => h.QuoteId == quoteId && h.CompanyId == companyId && !h.IsDeleted)
|
||||||
.Include(h => h.ChangedBy)
|
.Include(h => h.ChangedBy)
|
||||||
.OrderByDescending(h => h.ChangedAt)
|
.OrderByDescending(h => h.ChangedAt)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -83,10 +83,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId)
|
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.QuoteItems
|
return await _context.QuoteItems
|
||||||
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
|
.Where(qi => qi.QuoteId == quoteId && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
.ThenInclude(c => c.InventoryItem)
|
.ThenInclude(c => c.InventoryItem)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public class AccountingExportController : Controller
|
|||||||
.OrderBy(e => e.Date)
|
.OrderBy(e => e.Date)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(companyId, start, end);
|
||||||
|
|
||||||
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public class BillsController : Controller
|
|||||||
private readonly IAzureBlobStorageService _blobStorage;
|
private readonly IAzureBlobStorageService _blobStorage;
|
||||||
private readonly StorageSettings _storageSettings;
|
private readonly StorageSettings _storageSettings;
|
||||||
private readonly IAiUsageLogger _usageLogger;
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
public BillsController(
|
public BillsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -45,7 +46,8 @@ public class BillsController : Controller
|
|||||||
IAccountingAiService accountingAi,
|
IAccountingAiService accountingAi,
|
||||||
IAzureBlobStorageService blobStorage,
|
IAzureBlobStorageService blobStorage,
|
||||||
IOptions<StorageSettings> storageSettings,
|
IOptions<StorageSettings> storageSettings,
|
||||||
IAiUsageLogger usageLogger)
|
IAiUsageLogger usageLogger,
|
||||||
|
ITenantContext tenantContext)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -56,6 +58,7 @@ public class BillsController : Controller
|
|||||||
_blobStorage = blobStorage;
|
_blobStorage = blobStorage;
|
||||||
_storageSettings = storageSettings.Value;
|
_storageSettings = storageSettings.Value;
|
||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Index ----------------------------------------------------------------
|
// -- Index ----------------------------------------------------------------
|
||||||
@@ -64,7 +67,7 @@ public class BillsController : Controller
|
|||||||
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
||||||
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
||||||
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
||||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
/// "Unpaid" or "Overdue" � preventing them from inflating the "amount owed" summary.
|
||||||
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
||||||
@@ -81,10 +84,12 @@ public class BillsController : Controller
|
|||||||
searchAmount = parsed;
|
searchAmount = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Bills
|
// Bills
|
||||||
if (type == null || type == "Bill")
|
if (type == null || type == "Bill")
|
||||||
{
|
{
|
||||||
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount);
|
var bills = await _unitOfWork.Bills.GetForIndexAsync(companyId, status, search, searchAmount);
|
||||||
|
|
||||||
entries.AddRange(bills.Select(b => new BillExpenseListDto
|
entries.AddRange(bills.Select(b => new BillExpenseListDto
|
||||||
{
|
{
|
||||||
@@ -112,7 +117,7 @@ public class BillsController : Controller
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
// Expenses are always fully paid � exclude when filtering to unpaid/overdue bills only
|
||||||
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
||||||
{
|
{
|
||||||
var expSearch = search;
|
var expSearch = search;
|
||||||
@@ -166,7 +171,7 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
||||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed � earlier states mean
|
||||||
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
||||||
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
||||||
/// Line items are copied from PO items (using inventory item names where available), and
|
/// Line items are copied from PO items (using inventory item names where available), and
|
||||||
@@ -291,7 +296,7 @@ public class BillsController : Controller
|
|||||||
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
||||||
/// before validation to avoid spurious errors when the browser submits blank rows.
|
/// before validation to avoid spurious errors when the browser submits blank rows.
|
||||||
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
||||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> �
|
||||||
/// useful for entering historical bills that were already settled. Account balance side
|
/// useful for entering historical bills that were already settled. Account balance side
|
||||||
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
||||||
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
||||||
@@ -322,7 +327,7 @@ public class BillsController : Controller
|
|||||||
{
|
{
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
// Period lock check — block if the bill date is in a locked period
|
// Period lock check � block if the bill date is in a locked period
|
||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||||
@@ -399,7 +404,7 @@ public class BillsController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Receipt upload after the transaction commits — bill.Id is set and core data
|
// Receipt upload after the transaction commits � bill.Id is set and core data
|
||||||
// is secure. A blob failure here leaves the bill intact without an attachment.
|
// is secure. A blob failure here leaves the bill intact without an attachment.
|
||||||
if (receiptFile != null && receiptFile.Length > 0)
|
if (receiptFile != null && receiptFile.Length > 0)
|
||||||
{
|
{
|
||||||
@@ -439,7 +444,8 @@ public class BillsController : Controller
|
|||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
|
|
||||||
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value, companyId);
|
||||||
if (bill == null) return NotFound();
|
if (bill == null) return NotFound();
|
||||||
|
|
||||||
var dto = _mapper.Map<BillDto>(bill);
|
var dto = _mapper.Map<BillDto>(bill);
|
||||||
@@ -454,7 +460,7 @@ public class BillsController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.BankAccounts = bankAccounts
|
ViewBag.BankAccounts = bankAccounts
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} � {a.Name}", a.Id.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||||
@@ -477,7 +483,8 @@ public class BillsController : Controller
|
|||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
|
|
||||||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value, companyId);
|
||||||
if (bill == null) return NotFound();
|
if (bill == null) return NotFound();
|
||||||
|
|
||||||
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
|
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
|
||||||
@@ -540,7 +547,8 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var bill = await _unitOfWork.Bills.LoadForEditAsync(id, companyId);
|
||||||
|
|
||||||
if (bill == null) return NotFound();
|
if (bill == null) return NotFound();
|
||||||
|
|
||||||
@@ -867,7 +875,7 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
||||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account � any payments
|
||||||
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
||||||
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
||||||
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
||||||
@@ -968,7 +976,8 @@ public class BillsController : Controller
|
|||||||
private async Task<string> GenerateBillNumberAsync()
|
private async Task<string> GenerateBillNumberAsync()
|
||||||
{
|
{
|
||||||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||||
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(companyId, prefix);
|
||||||
|
|
||||||
int next = 1;
|
int next = 1;
|
||||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||||
@@ -979,13 +988,14 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> � soft-deleted
|
||||||
/// records are included in the scan so payment numbers are never reused.
|
/// records are included in the scan so payment numbers are never reused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> GeneratePaymentNumberAsync()
|
private async Task<string> GeneratePaymentNumberAsync()
|
||||||
{
|
{
|
||||||
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
|
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
|
||||||
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(companyId, prefix);
|
||||||
|
|
||||||
int next = 1;
|
int next = 1;
|
||||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||||
@@ -1139,13 +1149,13 @@ public class BillsController : Controller
|
|||||||
// -- AI: Recurring Bill Detection ------------------------------------------
|
// -- AI: Recurring Bill Detection ------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
|
/// GET page � displays the recurring bill detection tool. No data is pre-fetched here;
|
||||||
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IActionResult RecurringDetection() => View();
|
public IActionResult RecurringDetection() => View();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
|
/// AJAX POST � loads up to 12 months of bill history for the company and passes it to
|
||||||
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
||||||
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
||||||
/// Results are returned as JSON for client-side rendering in the view.
|
/// Results are returned as JSON for client-side rendering in the view.
|
||||||
|
|||||||
@@ -1103,7 +1103,7 @@ public class InvoicesController : Controller
|
|||||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||||
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
|
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
|
||||||
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
|
||||||
this.SetNotificationResultToast(notifLog);
|
this.SetNotificationResultToast(notifLog);
|
||||||
}
|
}
|
||||||
catch (Exception notifyEx)
|
catch (Exception notifyEx)
|
||||||
@@ -1190,7 +1190,7 @@ public class InvoicesController : Controller
|
|||||||
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
|
||||||
this.SetNotificationResultToast(notifLog);
|
this.SetNotificationResultToast(notifLog);
|
||||||
|
|
||||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
||||||
@@ -1321,7 +1321,7 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
this.SetNotificationResultToast(paymentNotifLog);
|
this.SetNotificationResultToast(paymentNotifLog);
|
||||||
|
|
||||||
TempData["Success"] = overpayment > 0
|
TempData["Success"] = overpayment > 0
|
||||||
@@ -1954,7 +1954,7 @@ public class InvoicesController : Controller
|
|||||||
sendSms: sendSms,
|
sendSms: sendSms,
|
||||||
viewUrl: viewUrl);
|
viewUrl: viewUrl);
|
||||||
|
|
||||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
|
||||||
|
|
||||||
if (latestLog?.Status == NotificationStatus.Failed)
|
if (latestLog?.Status == NotificationStatus.Failed)
|
||||||
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
|
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
|
||||||
@@ -1987,7 +1987,7 @@ public class InvoicesController : Controller
|
|||||||
public async Task<IActionResult> NotificationsSent(int id)
|
public async Task<IActionResult> NotificationsSent(int id)
|
||||||
{
|
{
|
||||||
var tz = ViewBag.CompanyTimeZone as string;
|
var tz = ViewBag.CompanyTimeZone as string;
|
||||||
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id);
|
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message,
|
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message,
|
||||||
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||||
@@ -2107,7 +2107,7 @@ public class InvoicesController : Controller
|
|||||||
/// eight-table include chain. Returns null if not found or soft-deleted.
|
/// eight-table include chain. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
|
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
|
||||||
await _unitOfWork.Invoices.LoadForViewAsync(id);
|
await _unitOfWork.Invoices.LoadForViewAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
|
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public class JobTemplatesController : Controller
|
|||||||
{
|
{
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
|
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId, companyId);
|
||||||
|
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ public class JobsController : Controller
|
|||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req)
|
public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req)
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId);
|
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
|
|
||||||
if (job == null)
|
if (job == null)
|
||||||
return Json(new { success = false, message = "Job not found." });
|
return Json(new { success = false, message = "Job not found." });
|
||||||
@@ -396,7 +396,8 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId);
|
||||||
if (job == null)
|
if (job == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -416,7 +417,7 @@ public class JobsController : Controller
|
|||||||
var jobDto = _mapper.Map<JobDto>(job);
|
var jobDto = _mapper.Map<JobDto>(job);
|
||||||
|
|
||||||
// Load change history
|
// Load change history
|
||||||
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value);
|
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value, companyId);
|
||||||
_logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value);
|
_logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value);
|
||||||
|
|
||||||
var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories);
|
var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories);
|
||||||
@@ -741,7 +742,8 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId ?? 0);
|
||||||
if (job == null)
|
if (job == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -751,7 +753,6 @@ public class JobsController : Controller
|
|||||||
var jobDto = _mapper.Map<JobDto>(job);
|
var jobDto = _mapper.Map<JobDto>(job);
|
||||||
|
|
||||||
// Get company info for the header
|
// Get company info for the header
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId.HasValue)
|
if (companyId.HasValue)
|
||||||
{
|
{
|
||||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||||
@@ -1268,7 +1269,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value);
|
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (job == null)
|
if (job == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -1782,7 +1783,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogWarning(ex, "Notification failed for job {Id}", job.Id);
|
_logger.LogWarning(ex, "Notification failed for job {Id}", job.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id);
|
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id, job.CompanyId);
|
||||||
this.SetNotificationResultToast(editNotifLog);
|
this.SetNotificationResultToast(editNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1996,10 +1997,9 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
|
||||||
if (source == null) return NotFound();
|
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id, companyId);
|
||||||
|
if (source == null) return NotFound();
|
||||||
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
|
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
|
||||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
|
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
|
||||||
if (pendingStatus == null)
|
if (pendingStatus == null)
|
||||||
@@ -2410,7 +2410,7 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId);
|
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (job == null) return Json(new { success = false, message = "Job not found" });
|
if (job == null) return Json(new { success = false, message = "Job not found" });
|
||||||
|
|
||||||
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId);
|
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId);
|
||||||
@@ -2661,7 +2661,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
|
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId);
|
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId, job.CompanyId);
|
||||||
this.SetNotificationResultToast(statusNotifLog);
|
this.SetNotificationResultToast(statusNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2888,7 +2888,7 @@ public class JobsController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> CompleteJobModal(int id)
|
public async Task<IActionResult> CompleteJobModal(int id)
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
@@ -3072,7 +3072,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId);
|
_logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId);
|
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
this.SetNotificationResultToast(completeNotifLog);
|
this.SetNotificationResultToast(completeNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3879,7 +3879,7 @@ public class JobsController : Controller
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId);
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var companyId = job.CompanyId;
|
var companyId = job.CompanyId;
|
||||||
@@ -3957,7 +3957,7 @@ public class JobsController : Controller
|
|||||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||||
if (reworkRecord == null) return NotFound();
|
if (reworkRecord == null) return NotFound();
|
||||||
|
|
||||||
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
|
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (originalJob == null) return NotFound();
|
if (originalJob == null) return NotFound();
|
||||||
|
|
||||||
var companyId = originalJob.CompanyId;
|
var companyId = originalJob.CompanyId;
|
||||||
@@ -4013,7 +4013,7 @@ public class JobsController : Controller
|
|||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||||
|
|
||||||
// Sub-number: {parentJobNumber}-R{n+1}
|
// Sub-number: {parentJobNumber}-R{n+1}
|
||||||
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
|
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id, companyId);
|
||||||
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
||||||
|
|
||||||
var reworkJob = new Job
|
var reworkJob = new Job
|
||||||
@@ -4165,7 +4165,7 @@ public class JobsController : Controller
|
|||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ResyncFromQuote(int id)
|
public async Task<IActionResult> ResyncFromQuote(int id)
|
||||||
{
|
{
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
// Guard: only allow re-sync while job is pre-production
|
// Guard: only allow re-sync while job is pre-production
|
||||||
@@ -4205,7 +4205,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Load quote items with full coat + prep-service data
|
// Load quote items with full coat + prep-service data
|
||||||
var quote = job.Quote!;
|
var quote = job.Quote!;
|
||||||
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value);
|
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value, job.CompanyId);
|
||||||
|
|
||||||
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -288,8 +288,9 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
// Load quote with all navigations needed for the Details view
|
// Load quote with all navigations needed for the Details view
|
||||||
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value);
|
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value, companyId);
|
||||||
|
|
||||||
if (quote == null)
|
if (quote == null)
|
||||||
{
|
{
|
||||||
@@ -412,7 +413,7 @@ public class QuotesController : Controller
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Load change history
|
// Load change history
|
||||||
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
|
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value, companyId);
|
||||||
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
|
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
|
||||||
ViewBag.ChangeHistory = changeHistoryDtos;
|
ViewBag.ChangeHistory = changeHistoryDtos;
|
||||||
|
|
||||||
@@ -560,7 +561,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||||
|
|
||||||
// Get company info and logo
|
// Get company info and logo
|
||||||
@@ -1082,7 +1083,7 @@ public class QuotesController : Controller
|
|||||||
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
|
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id);
|
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id, currentUser!.CompanyId);
|
||||||
this.SetNotificationResultToast(quoteCreateNotifLog);
|
this.SetNotificationResultToast(quoteCreateNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,7 +1136,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get quote items with their coats, prep services and catalog item
|
// Get quote items with their coats, prep services and catalog item
|
||||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
|
|
||||||
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
|
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
|
||||||
foreach (var item in quoteItems)
|
foreach (var item in quoteItems)
|
||||||
@@ -2197,7 +2198,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||||
|
|
||||||
// Warn on confirmation page if a job is linked
|
// Warn on confirmation page if a job is linked
|
||||||
@@ -2356,7 +2357,7 @@ public class QuotesController : Controller
|
|||||||
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
|
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
|
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
|
||||||
this.SetNotificationResultToast(approveNotifLog);
|
this.SetNotificationResultToast(approveNotifLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2718,7 +2719,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId);
|
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId, currentUser.CompanyId);
|
||||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||||
|
|
||||||
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||||
@@ -2892,7 +2893,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
// Always reload quote items with full coat/prep-service data so this works
|
// Always reload quote items with full coat/prep-service data so this works
|
||||||
// regardless of which caller loaded the quote (some callers don't include coats).
|
// regardless of which caller loaded the quote (some callers don't include coats).
|
||||||
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id);
|
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id, quote.CompanyId);
|
||||||
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
||||||
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
||||||
|
|
||||||
@@ -3210,7 +3211,7 @@ public class QuotesController : Controller
|
|||||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
||||||
|
|
||||||
// Check the most recent log entry to get actual send status
|
// Check the most recent log entry to get actual send status
|
||||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
|
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
|
||||||
|
|
||||||
if (latestLog?.Status == NotificationStatus.Failed)
|
if (latestLog?.Status == NotificationStatus.Failed)
|
||||||
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
||||||
@@ -3312,7 +3313,7 @@ public class QuotesController : Controller
|
|||||||
public async Task<IActionResult> NotificationsSent(int id)
|
public async Task<IActionResult> NotificationsSent(int id)
|
||||||
{
|
{
|
||||||
var tz = ViewBag.CompanyTimeZone as string;
|
var tz = ViewBag.CompanyTimeZone as string;
|
||||||
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id);
|
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
|
||||||
var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage,
|
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage,
|
||||||
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||||
|
|||||||
Reference in New Issue
Block a user