Added the billedTo in collection module and removed the project forign key and update so able to accept both infra project and service project
This commit is contained in:
parent
b247ed36ed
commit
76e77eb50f
8802
Marco.Pms.DataAccess/Migrations/20251117135930_Added_BilledTo_In_Invoice_Table.Designer.cs
generated
Normal file
8802
Marco.Pms.DataAccess/Migrations/20251117135930_Added_BilledTo_In_Invoice_Table.Designer.cs
generated
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marco.Pms.DataAccess.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Added_BilledTo_In_Invoice_Table : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Invoices_Projects_ProjectId",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Invoices_ProjectId",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "BilledToId",
|
||||
table: "Invoices",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_BilledToId",
|
||||
table: "Invoices",
|
||||
column: "BilledToId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Invoices_Organizations_BilledToId",
|
||||
table: "Invoices",
|
||||
column: "BilledToId",
|
||||
principalTable: "Organizations",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Invoices_Organizations_BilledToId",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Invoices_BilledToId",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BilledToId",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_ProjectId",
|
||||
table: "Invoices",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Invoices_Projects_ProjectId",
|
||||
table: "Invoices",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -378,6 +378,9 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
b.Property<double>("BasicAmount")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<Guid?>("BilledToId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("ClientSubmitedDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@ -431,9 +434,9 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
b.HasIndex("BilledToId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
@ -6528,18 +6531,16 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
modelBuilder.Entity("Marco.Pms.Model.Collection.Invoice", b =>
|
||||
{
|
||||
b.HasOne("Marco.Pms.Model.OrganizationModel.Organization", "BilledTo")
|
||||
.WithMany()
|
||||
.HasForeignKey("BilledToId");
|
||||
|
||||
b.HasOne("Marco.Pms.Model.Employees.Employee", "CreatedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
@ -6550,9 +6551,9 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedById");
|
||||
|
||||
b.Navigation("CreatedBy");
|
||||
b.Navigation("BilledTo");
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("CreatedBy");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
using Marco.Pms.Model.Employees;
|
||||
using Marco.Pms.Model.Projects;
|
||||
using Marco.Pms.Model.OrganizationModel;
|
||||
using Marco.Pms.Model.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@ -11,13 +11,14 @@ namespace Marco.Pms.Model.Collection
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = default!;
|
||||
public string Description { get; set; } = default!;
|
||||
public Guid? BilledToId { get; set; }
|
||||
|
||||
[ValidateNever]
|
||||
[ForeignKey("BilledToId")]
|
||||
public Organization? BilledTo { get; set; }
|
||||
public string InvoiceNumber { get; set; } = default!;
|
||||
public string? EInvoiceNumber { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
|
||||
[ValidateNever]
|
||||
[ForeignKey("ProjectId")]
|
||||
public Project? Project { get; set; }
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public DateTime ClientSubmitedDate { get; set; }
|
||||
public DateTime ExceptedPaymentDate { get; set; }
|
||||
|
||||
@ -10,6 +10,7 @@ namespace Marco.Pms.Model.Dtos.Collection
|
||||
public required string InvoiceNumber { get; set; }
|
||||
public string? EInvoiceNumber { get; set; }
|
||||
public required Guid ProjectId { get; set; }
|
||||
public required Guid BilledToId { get; set; }
|
||||
public required DateTime InvoiceDate { get; set; }
|
||||
public required DateTime ClientSubmitedDate { get; set; }
|
||||
public required DateTime ExceptedPaymentDate { get; set; }
|
||||
|
||||
@ -5,8 +5,8 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
|
||||
{
|
||||
public class JobAttendanceDto
|
||||
{
|
||||
public Guid JobTcketId { get; set; }
|
||||
public TAGGING_MARK_TYPE Action { get; set; }
|
||||
public required Guid JobTcketId { get; set; }
|
||||
public required TAGGING_MARK_TYPE Action { get; set; }
|
||||
public string? Latitude { get; set; }
|
||||
public string? Longitude { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Marco.Pms.Model.ViewModels.Activities;
|
||||
using Marco.Pms.Model.ViewModels.Organization;
|
||||
using Marco.Pms.Model.ViewModels.Projects;
|
||||
|
||||
namespace Marco.Pms.Model.ViewModels.Collection
|
||||
@ -11,6 +12,7 @@ namespace Marco.Pms.Model.ViewModels.Collection
|
||||
public string InvoiceNumber { get; set; } = default!;
|
||||
public string? EInvoiceNumber { get; set; }
|
||||
public BasicProjectVM? Project { get; set; }
|
||||
public BasicOrganizationVm? BilledTo { get; set; }
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public DateTime ClientSubmitedDate { get; set; }
|
||||
public DateTime ExceptedPaymentDate { get; set; }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Marco.Pms.Model.ViewModels.Activities;
|
||||
using Marco.Pms.Model.ViewModels.Organization;
|
||||
using Marco.Pms.Model.ViewModels.Projects;
|
||||
|
||||
namespace Marco.Pms.Model.ViewModels.Collection
|
||||
@ -10,6 +11,7 @@ namespace Marco.Pms.Model.ViewModels.Collection
|
||||
public string Description { get; set; } = default!;
|
||||
public string InvoiceNumber { get; set; } = default!;
|
||||
public string? EInvoiceNumber { get; set; }
|
||||
public BasicOrganizationVm? BilledTo { get; set; }
|
||||
public BasicProjectVM? Project { get; set; }
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public DateTime ClientSubmitedDate { get; set; }
|
||||
|
||||
@ -2,21 +2,23 @@
|
||||
using Marco.Pms.DataAccess.Data;
|
||||
using Marco.Pms.Helpers.Utility;
|
||||
using Marco.Pms.Model.Collection;
|
||||
using Marco.Pms.Model.DocumentManager;
|
||||
using Marco.Pms.Model.Dtos.Collection;
|
||||
using Marco.Pms.Model.Entitlements;
|
||||
using Marco.Pms.Model.MongoDBModels.Utility;
|
||||
using Marco.Pms.Model.Utilities;
|
||||
using Marco.Pms.Model.ViewModels.Activities;
|
||||
using Marco.Pms.Model.ViewModels.Collection;
|
||||
using Marco.Pms.Model.ViewModels.Organization;
|
||||
using Marco.Pms.Model.ViewModels.Projects;
|
||||
using Marco.Pms.Services.Service;
|
||||
using MarcoBMS.Services.Helpers;
|
||||
using MarcoBMS.Services.Service;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MongoDB.Driver;
|
||||
using Document = Marco.Pms.Model.DocumentManager.Document;
|
||||
|
||||
namespace Marco.Pms.Services.Controllers
|
||||
{
|
||||
@ -50,184 +52,167 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
#region =================================================================== Get Functions ===================================================================
|
||||
|
||||
[HttpGet("invoice/list")]
|
||||
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1
|
||||
, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
|
||||
/// <summary>
|
||||
/// Fetches a paginated and filtered list of invoices after validating permissions.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate,
|
||||
[FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInfo(
|
||||
"Fetching invoice list: Page {PageNumber}, Size {PageSize}, Active={IsActive}, PendingOnly={IsPending}, Search='{SearchString}', From={From}, To={To}",
|
||||
pageNumber, pageSize, isActive, isPending, searchString ?? "", fromDate?.Date ?? DateTime.MinValue, toDate?.Date ?? DateTime.MaxValue);
|
||||
"GetInvoiceListAsync called. Page: {PageNumber}, Size: {PageSize}, Active: {IsActive}, Pending: {IsPending}, Search: '{Search}', From: {From}, To: {To}",
|
||||
pageNumber, pageSize, isActive, isPending, searchString ?? string.Empty, fromDate ?? DateTime.UtcNow, toDate ?? DateTime.UtcNow);
|
||||
|
||||
// Get the currently logged-in employee
|
||||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
||||
// Validate user identity and permissions in parallel for best performance
|
||||
var employee = await _userHelper.GetCurrentEmployeeAsync();
|
||||
_logger.LogInfo("Performing permission checks for EmployeeId: {EmployeeId}", employee.Id);
|
||||
|
||||
// Log starting permission checks
|
||||
_logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id);
|
||||
|
||||
// Initiate permission check tasks asynchronously
|
||||
var adminPermissionTask = Task.Run(async () =>
|
||||
var permissionTasks = new[]
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id);
|
||||
});
|
||||
HasPermissionAsync(PermissionsMaster.CollectionAdmin, employee.Id),
|
||||
HasPermissionAsync(PermissionsMaster.ViewCollection, employee.Id),
|
||||
HasPermissionAsync(PermissionsMaster.CreateCollection, employee.Id),
|
||||
HasPermissionAsync(PermissionsMaster.EditCollection, employee.Id),
|
||||
HasPermissionAsync(PermissionsMaster.AddPayment, employee.Id)
|
||||
};
|
||||
|
||||
var viewPermissionTask = Task.Run(async () =>
|
||||
await Task.WhenAll(permissionTasks);
|
||||
|
||||
if (permissionTasks.All(t => !t.Result))
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id);
|
||||
});
|
||||
|
||||
var createPermissionTask = Task.Run(async () =>
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id);
|
||||
});
|
||||
|
||||
var editPermissionTask = Task.Run(async () =>
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id);
|
||||
});
|
||||
|
||||
var addPaymentPermissionTask = Task.Run(async () =>
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id);
|
||||
});
|
||||
|
||||
// Await all permission checks to complete concurrently
|
||||
await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask);
|
||||
|
||||
// Capture permission results
|
||||
var hasAdminPermission = adminPermissionTask.Result;
|
||||
var hasViewPermission = viewPermissionTask.Result;
|
||||
var hasCreatePermission = createPermissionTask.Result;
|
||||
var hasEditPermission = editPermissionTask.Result;
|
||||
var hasAddPaymentPermission = addPaymentPermissionTask.Result;
|
||||
|
||||
// Log permission results for audit
|
||||
_logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}",
|
||||
loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission);
|
||||
|
||||
// Check if user has any relevant permission; if none, deny access
|
||||
if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission)
|
||||
{
|
||||
_logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id);
|
||||
_logger.LogWarning("Access denied. EmployeeId {EmployeeId} lacks relevant collection permissions.", employee.Id);
|
||||
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
||||
"Access Denied",
|
||||
"User does not have permission to access collection data.",
|
||||
"User does not have necessary permissions.",
|
||||
403));
|
||||
}
|
||||
|
||||
// Optionally log success or continue with further processing here
|
||||
_logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id);
|
||||
_logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id);
|
||||
|
||||
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Build base query with required includes and no tracking
|
||||
var invoicesQuery = _context.Invoices
|
||||
.Include(i => i.Project)
|
||||
// Build invoice query efficiently - always use AsNoTracking for reads
|
||||
var query = _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.BilledTo)
|
||||
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
|
||||
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
|
||||
.Where(i => i.IsActive == isActive && i.TenantId == tenantId)
|
||||
.AsNoTracking(); // Disable change tracking for read-only query
|
||||
.Where(i => i.IsActive == isActive && i.TenantId == tenantId);
|
||||
|
||||
// Apply date filter
|
||||
// Filter by date, ensuring date boundaries are correct
|
||||
if (fromDate.HasValue && toDate.HasValue)
|
||||
{
|
||||
var fromDateUtc = fromDate.Value.Date;
|
||||
var toDateUtc = toDate.Value.Date.AddDays(1).AddTicks(-1); // End of day
|
||||
invoicesQuery = invoicesQuery.Where(i => i.InvoiceDate >= fromDateUtc && i.InvoiceDate <= toDateUtc);
|
||||
_logger.LogDebug("Applied date filter: {From} to {To}", fromDateUtc, toDateUtc);
|
||||
var fromUtc = fromDate.Value.Date;
|
||||
var toUtc = toDate.Value.Date.AddDays(1).AddTicks(-1);
|
||||
query = query.Where(i => i.InvoiceDate >= fromUtc && i.InvoiceDate <= toUtc);
|
||||
_logger.LogDebug("Date filter applied: {From} to {To}", fromUtc, toUtc);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (!string.IsNullOrWhiteSpace(searchString))
|
||||
if (!string.IsNullOrEmpty(searchString))
|
||||
{
|
||||
invoicesQuery = invoicesQuery.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
|
||||
_logger.LogDebug("Applied search filter with term: {SearchString}", searchString);
|
||||
query = query.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
|
||||
_logger.LogDebug("Search filter applied: '{Search}'", searchString);
|
||||
}
|
||||
|
||||
// Apply project filter
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
invoicesQuery = invoicesQuery.Where(i => i.ProjectId == projectId.Value);
|
||||
_logger.LogDebug("Applied project filter with term: {ProjectId}", projectId);
|
||||
query = query.Where(i => i.ProjectId == projectId.Value);
|
||||
_logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value);
|
||||
}
|
||||
|
||||
// Get total count before pagination
|
||||
var totalEntites = await invoicesQuery.CountAsync();
|
||||
_logger.LogDebug("Total matching invoices: {TotalCount}", totalEntites);
|
||||
var totalItems = await query.CountAsync();
|
||||
_logger.LogInfo("Total invoices found: {TotalItems}", totalItems);
|
||||
|
||||
// Apply sorting and pagination
|
||||
var invoices = await invoicesQuery
|
||||
var pagedInvoices = await query
|
||||
.OrderByDescending(i => i.InvoiceDate)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
if (!invoices.Any())
|
||||
if (!pagedInvoices.Any())
|
||||
{
|
||||
_logger.LogInfo("No invoices found for the given criteria.");
|
||||
var emptyResponse = new
|
||||
{
|
||||
CurrentPage = pageNumber,
|
||||
TotalPages = 0,
|
||||
TotalEntites = 0,
|
||||
Data = new List<InvoiceListVM>()
|
||||
};
|
||||
return Ok(ApiResponse<object>.SuccessResponse(emptyResponse, "No invoices found"));
|
||||
_logger.LogInfo("No invoices match criteria.");
|
||||
return Ok(ApiResponse<object>.SuccessResponse(
|
||||
new { CurrentPage = pageNumber, TotalPages = 0, TotalEntities = 0, Data = Array.Empty<InvoiceListVM>() },
|
||||
"No invoices found."
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch all related payment data in a single query
|
||||
var invoiceIds = invoices.Select(i => i.Id).ToList();
|
||||
// Fetch related payments in a single query to minimize DB calls
|
||||
var invoiceIds = pagedInvoices.Select(i => i.Id).ToList();
|
||||
var paymentGroups = await _context.ReceivedInvoicePayments
|
||||
.AsNoTracking()
|
||||
.Where(rip => invoiceIds.Contains(rip.InvoiceId) && rip.TenantId == tenantId)
|
||||
.GroupBy(rip => rip.InvoiceId)
|
||||
.Select(g => new
|
||||
{
|
||||
InvoiceId = g.Key,
|
||||
PaidAmount = g.Sum(rip => rip.Amount)
|
||||
})
|
||||
.ToDictionaryAsync(x => x.InvoiceId, x => x.PaidAmount);
|
||||
.Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId)
|
||||
.GroupBy(p => p.InvoiceId)
|
||||
.Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.InvoiceId, g => g.PaidAmount);
|
||||
|
||||
_logger.LogDebug("Fetched payment data for {Count} invoices", paymentGroups.Count);
|
||||
_logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count);
|
||||
|
||||
// Map and calculate balance in memory
|
||||
// Fetch related project data asynchronously and in parallel
|
||||
var projIds = pagedInvoices.Select(i => i.ProjectId).Distinct().ToList();
|
||||
var infraProjectsTask = _context.Projects
|
||||
.Where(p => projIds.Contains(p.Id) && p.TenantId == tenantId)
|
||||
.ToListAsync();
|
||||
var serviceProjectsTask = context.ServiceProjects
|
||||
.Where(sp => projIds.Contains(sp.Id) && sp.TenantId == tenantId)
|
||||
.ToListAsync();
|
||||
|
||||
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
|
||||
|
||||
var infraProjects = infraProjectsTask.Result;
|
||||
var serviceProjects = serviceProjectsTask.Result;
|
||||
|
||||
// Build results and compute balances in memory for tight control
|
||||
var results = new List<InvoiceListVM>();
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
var totalAmount = invoice.BasicAmount + invoice.TaxAmount;
|
||||
var paidAmount = paymentGroups.GetValueOrDefault(invoice.Id, 0);
|
||||
var balanceAmount = totalAmount - paidAmount;
|
||||
|
||||
// Skip if filtering for pending invoices and balance is zero
|
||||
if (isPending && (balanceAmount <= 0 || invoice.MarkAsCompleted))
|
||||
foreach (var invoice in pagedInvoices)
|
||||
{
|
||||
var total = invoice.BasicAmount + invoice.TaxAmount;
|
||||
var paid = paymentGroups.GetValueOrDefault(invoice.Id, 0);
|
||||
var balance = total - paid;
|
||||
|
||||
// Filter pending
|
||||
if (isPending && (balance <= 0 || invoice.MarkAsCompleted))
|
||||
continue;
|
||||
|
||||
var result = _mapper.Map<InvoiceListVM>(invoice);
|
||||
result.BalanceAmount = balanceAmount;
|
||||
results.Add(result);
|
||||
var vm = _mapper.Map<InvoiceListVM>(invoice);
|
||||
|
||||
// Project mapping logic - minimize nested object allocations
|
||||
vm.Project = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map<BasicProjectVM>(p)).FirstOrDefault()
|
||||
?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map<BasicProjectVM>(sp)).FirstOrDefault();
|
||||
|
||||
|
||||
vm.BalanceAmount = balance;
|
||||
results.Add(vm);
|
||||
}
|
||||
|
||||
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
|
||||
var response = new
|
||||
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
|
||||
|
||||
_logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages);
|
||||
|
||||
return Ok(ApiResponse<object>.SuccessResponse(
|
||||
new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalItems, Data = results },
|
||||
$"{results.Count} invoices fetched successfully."
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CurrentPage = pageNumber,
|
||||
TotalPages = totalPages,
|
||||
TotalEntites = totalEntites,
|
||||
Data = results
|
||||
};
|
||||
// Centralized and structured error logging
|
||||
_logger.LogError(ex, "Error in GetInvoiceListAsync: {Message}", ex.Message);
|
||||
|
||||
_logger.LogInfo("Successfully returned {ResultCount} invoices out of {TotalCount} total", results.Count, totalEntites);
|
||||
return Ok(ApiResponse<object>.SuccessResponse(response, $"{results.Count} invoices fetched successfully"));
|
||||
// Use standardized error response structure
|
||||
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
||||
"Internal Server Error",
|
||||
"An unexpected error occurred while fetching invoices.",
|
||||
500));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves complete details of a specific invoice including associated comments, attachments, and payments.
|
||||
@ -313,7 +298,7 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
// Retrieve primary invoice details with related entities (project, created/updated by + roles)
|
||||
var invoice = await context.Invoices
|
||||
.Include(i => i.Project)
|
||||
.Include(i => i.BilledTo)
|
||||
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
|
||||
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
|
||||
.AsNoTracking()
|
||||
@ -341,6 +326,35 @@ namespace Marco.Pms.Services.Controllers
|
||||
// Map invoice to response view model
|
||||
var response = _mapper.Map<InvoiceDetailsVM>(invoice);
|
||||
|
||||
var infraProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.Projects.Where(p => p.Id == invoice.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
var serviceProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.ServiceProjects.Where(sp => sp.Id == invoice.ProjectId && sp.TenantId == tenantId).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
await Task.WhenAll(infraProjectTask, serviceProjectTask);
|
||||
|
||||
var infraProject = infraProjectTask.Result;
|
||||
var serviceProject = serviceProjectTask.Result;
|
||||
|
||||
if (serviceProject == null)
|
||||
{
|
||||
if (infraProject == null)
|
||||
{
|
||||
response.Project = _mapper.Map<BasicProjectVM>(infraProject);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Project = _mapper.Map<BasicProjectVM>(serviceProject);
|
||||
}
|
||||
|
||||
// Populate related data
|
||||
if (comments.Any())
|
||||
response.Comments = _mapper.Map<List<InvoiceCommentVM>>(comments);
|
||||
@ -512,15 +526,57 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
|
||||
// Fetch project
|
||||
var project = await _context.Projects
|
||||
.FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId);
|
||||
if (project == null)
|
||||
var infraProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.Projects.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
var serviceProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.ServiceProjects.Where(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync();
|
||||
});
|
||||
var billedToTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.Organizations.Where(o => o.Id == model.BilledToId && o.IsActive).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
await Task.WhenAll(infraProjectTask, serviceProjectTask, billedToTask);
|
||||
|
||||
var infraProject = infraProjectTask.Result;
|
||||
var serviceProject = serviceProjectTask.Result;
|
||||
var billedTo = billedToTask.Result;
|
||||
|
||||
if (serviceProject == null && infraProject == null)
|
||||
{
|
||||
_logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}",
|
||||
model.ProjectId, tenantId);
|
||||
return NotFound(ApiResponse<object>.ErrorResponse("Project not found", "Project not found", 404));
|
||||
}
|
||||
|
||||
if (billedTo == null)
|
||||
{
|
||||
return NotFound(ApiResponse<object>.ErrorResponse("Organization not found", "Organization not found", 404));
|
||||
}
|
||||
|
||||
BasicProjectVM? projectVM = null;
|
||||
string objectKeyPerfix = "";
|
||||
if (serviceProject == null)
|
||||
{
|
||||
if (infraProject != null)
|
||||
{
|
||||
projectVM = _mapper.Map<BasicProjectVM>(infraProject);
|
||||
objectKeyPerfix = $"tenant-{tenantId}/Project/{model.ProjectId}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
projectVM = _mapper.Map<BasicProjectVM>(serviceProject);
|
||||
objectKeyPerfix = $"tenant-{tenantId}/ServiceProject/{model.ProjectId}";
|
||||
}
|
||||
|
||||
// Begin transaction scope with async flow support
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
var invoice = new Invoice();
|
||||
@ -555,7 +611,7 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
var fileType = _s3Service.GetContentTypeFromBase64(base64);
|
||||
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice");
|
||||
var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}";
|
||||
var objectKey = $"{objectKeyPerfix}/Invoice/{fileName}";
|
||||
|
||||
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
|
||||
|
||||
@ -603,7 +659,8 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
// Build response
|
||||
var response = _mapper.Map<InvoiceListVM>(invoice);
|
||||
response.Project = _mapper.Map<BasicProjectVM>(project);
|
||||
response.Project = projectVM;
|
||||
response.BilledTo = _mapper.Map<BasicOrganizationVm>(billedTo);
|
||||
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
||||
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
|
||||
|
||||
@ -987,15 +1044,58 @@ namespace Marco.Pms.Services.Controllers
|
||||
}
|
||||
|
||||
// Fetch project
|
||||
var project = await _context.Projects
|
||||
.FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId);
|
||||
if (project == null)
|
||||
var infraProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.Projects.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
var serviceProjectTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.ServiceProjects.Where(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync();
|
||||
});
|
||||
var billedToTask = Task.Run(async () =>
|
||||
{
|
||||
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.Organizations.Where(o => o.Id == model.BilledToId && o.IsActive).FirstOrDefaultAsync();
|
||||
});
|
||||
|
||||
await Task.WhenAll(infraProjectTask, serviceProjectTask, billedToTask);
|
||||
|
||||
var infraProject = infraProjectTask.Result;
|
||||
var serviceProject = serviceProjectTask.Result;
|
||||
var billedTo = billedToTask.Result;
|
||||
|
||||
|
||||
if (serviceProject == null && infraProject == null)
|
||||
{
|
||||
_logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}",
|
||||
model.ProjectId, tenantId);
|
||||
return NotFound(ApiResponse<object>.ErrorResponse("Project not found", "Project not found", 404));
|
||||
}
|
||||
|
||||
if (billedTo == null)
|
||||
{
|
||||
return NotFound(ApiResponse<object>.ErrorResponse("Organization not found", "Organization not found", 404));
|
||||
}
|
||||
|
||||
BasicProjectVM? projectVM = null;
|
||||
string objectKeyPerfix = "";
|
||||
if (serviceProject == null)
|
||||
{
|
||||
if (infraProject != null)
|
||||
{
|
||||
projectVM = _mapper.Map<BasicProjectVM>(infraProject);
|
||||
objectKeyPerfix = $"tenant-{tenantId}/Project/{model.ProjectId}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
projectVM = _mapper.Map<BasicProjectVM>(serviceProject);
|
||||
objectKeyPerfix = $"tenant-{tenantId}/ServiceProject/{model.ProjectId}";
|
||||
}
|
||||
|
||||
// Prevent modification if any payment has already been received
|
||||
var receivedPaymentExists = await _context.ReceivedInvoicePayments
|
||||
.AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId);
|
||||
@ -1064,7 +1164,7 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
var contentType = _s3Service.GetContentTypeFromBase64(base64Data);
|
||||
var fileName = _s3Service.GenerateFileName(contentType, tenantId, "invoice");
|
||||
var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}";
|
||||
var objectKey = $"{objectKeyPerfix}/Invoice/{fileName}";
|
||||
|
||||
// Upload file to S3
|
||||
await _s3Service.UploadFileAsync(base64Data, contentType, objectKey);
|
||||
@ -1116,7 +1216,8 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
// Build response
|
||||
var response = _mapper.Map<InvoiceListVM>(invoice);
|
||||
response.Project = _mapper.Map<BasicProjectVM>(project);
|
||||
response.Project = projectVM;
|
||||
response.BilledTo = _mapper.Map<BasicOrganizationVm>(billedTo);
|
||||
response.UpdatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
||||
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
|
||||
|
||||
@ -1236,6 +1337,16 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
#region =================================================================== Helper Functions ===================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Async permission check helper with scoped DI lifetime
|
||||
/// </summary>
|
||||
private async Task<bool> HasPermissionAsync(Guid permission, Guid employeeId)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
return await permissionService.HasPermission(permission, employeeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads invoice comments asynchronously with related metadata.
|
||||
/// </summary>
|
||||
|
||||
@ -194,11 +194,17 @@ namespace Marco.Pms.Services.MappingProfiles
|
||||
|
||||
|
||||
CreateMap<ServiceProjectDto, ServiceProject>();
|
||||
CreateMap<ServiceProject, BasicProjectVM>();
|
||||
CreateMap<ServiceProject, ServiceProjectVM>();
|
||||
CreateMap<ServiceProject, BasicServiceProjectVM>();
|
||||
CreateMap<ServiceProject, ServiceProjectDetailsVM>();
|
||||
CreateMap<ServiceProjectAllocation, ServiceProjectAllocationVM>();
|
||||
|
||||
//#region ======================================================= Talking Points =======================================================
|
||||
//CreateMap<TalkingPointDto, TalkingPoint>();
|
||||
//CreateMap<TalkingPoint, TalkingPointVM>();
|
||||
//#endregion
|
||||
|
||||
#region ======================================================= Job Ticket =======================================================
|
||||
CreateMap<CreateJobTicketDto, JobTicket>();
|
||||
CreateMap<UpdateJobTicketDto, JobTicket>();
|
||||
|
||||
@ -40,8 +40,8 @@
|
||||
"BucketName": "testenv-marco-pms-documents"
|
||||
},
|
||||
"MongoDB": {
|
||||
"SerilogDatabaseUrl": "mongodb://localhost:27017/DotNetLogs",
|
||||
"ConnectionString": "mongodb://localhost:27017/MarcoBMS_Caches?socketTimeoutMS=500&serverSelectionTimeoutMS=500&connectTimeoutMS=500",
|
||||
"SerilogDatabaseUrl": "mongodb://devuser:DevPass123@147.93.98.152:27017/DotNetLogsLocalDev?authSource=admin&eplicaSet=rs01&directConnection=true",
|
||||
"ConnectionString": "mongodb://devuser:DevPass123@147.93.98.152:27017/MarcoBMSLocalCache?authSource=admin&eplicaSet=rs01&directConnection=true&socketTimeoutMS=500&serverSelectionTimeoutMS=500&connectTimeoutMS=500",
|
||||
"ModificationConnectionString": "mongodb://devuser:DevPass123@147.93.98.152:27017/MarcoBMSLocalDev?authSource=admin&eplicaSet=rs01&directConnection=true"
|
||||
},
|
||||
"Razorpay": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user