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:
ashutosh.nehete 2025-11-18 10:43:34 +05:30
parent b247ed36ed
commit 76e77eb50f
11 changed files with 9190 additions and 193 deletions

File diff suppressed because one or more lines are too long

View File

@ -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);
}
}
}

View File

@ -378,6 +378,9 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<double>("BasicAmount") b.Property<double>("BasicAmount")
.HasColumnType("double"); .HasColumnType("double");
b.Property<Guid?>("BilledToId")
.HasColumnType("char(36)");
b.Property<DateTime>("ClientSubmitedDate") b.Property<DateTime>("ClientSubmitedDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@ -431,9 +434,9 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedById"); b.HasIndex("BilledToId");
b.HasIndex("ProjectId"); b.HasIndex("CreatedById");
b.HasIndex("TenantId"); b.HasIndex("TenantId");
@ -6528,18 +6531,16 @@ namespace Marco.Pms.DataAccess.Migrations
modelBuilder.Entity("Marco.Pms.Model.Collection.Invoice", b => 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") b.HasOne("Marco.Pms.Model.Employees.Employee", "CreatedBy")
.WithMany() .WithMany()
.HasForeignKey("CreatedById") .HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant") b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant")
.WithMany() .WithMany()
.HasForeignKey("TenantId") .HasForeignKey("TenantId")
@ -6550,9 +6551,9 @@ namespace Marco.Pms.DataAccess.Migrations
.WithMany() .WithMany()
.HasForeignKey("UpdatedById"); .HasForeignKey("UpdatedById");
b.Navigation("CreatedBy"); b.Navigation("BilledTo");
b.Navigation("Project"); b.Navigation("CreatedBy");
b.Navigation("Tenant"); b.Navigation("Tenant");

View File

@ -1,5 +1,5 @@
using Marco.Pms.Model.Employees; using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Projects; using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
@ -11,13 +11,14 @@ namespace Marco.Pms.Model.Collection
public Guid Id { get; set; } public Guid Id { get; set; }
public string Title { get; set; } = default!; public string Title { get; set; } = default!;
public string Description { 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 InvoiceNumber { get; set; } = default!;
public string? EInvoiceNumber { get; set; } public string? EInvoiceNumber { get; set; }
public Guid ProjectId { get; set; } public Guid ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]
public Project? Project { get; set; }
public DateTime InvoiceDate { get; set; } public DateTime InvoiceDate { get; set; }
public DateTime ClientSubmitedDate { get; set; } public DateTime ClientSubmitedDate { get; set; }
public DateTime ExceptedPaymentDate { get; set; } public DateTime ExceptedPaymentDate { get; set; }

View File

@ -10,6 +10,7 @@ namespace Marco.Pms.Model.Dtos.Collection
public required string InvoiceNumber { get; set; } public required string InvoiceNumber { get; set; }
public string? EInvoiceNumber { get; set; } public string? EInvoiceNumber { get; set; }
public required Guid ProjectId { get; set; } public required Guid ProjectId { get; set; }
public required Guid BilledToId { get; set; }
public required DateTime InvoiceDate { get; set; } public required DateTime InvoiceDate { get; set; }
public required DateTime ClientSubmitedDate { get; set; } public required DateTime ClientSubmitedDate { get; set; }
public required DateTime ExceptedPaymentDate { get; set; } public required DateTime ExceptedPaymentDate { get; set; }

View File

@ -5,8 +5,8 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
{ {
public class JobAttendanceDto public class JobAttendanceDto
{ {
public Guid JobTcketId { get; set; } public required Guid JobTcketId { get; set; }
public TAGGING_MARK_TYPE Action { get; set; } public required TAGGING_MARK_TYPE Action { get; set; }
public string? Latitude { get; set; } public string? Latitude { get; set; }
public string? Longitude { get; set; } public string? Longitude { get; set; }
public string? Comment { get; set; } public string? Comment { get; set; }

View File

@ -1,4 +1,5 @@
using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.Projects;
namespace Marco.Pms.Model.ViewModels.Collection namespace Marco.Pms.Model.ViewModels.Collection
@ -11,6 +12,7 @@ namespace Marco.Pms.Model.ViewModels.Collection
public string InvoiceNumber { get; set; } = default!; public string InvoiceNumber { get; set; } = default!;
public string? EInvoiceNumber { get; set; } public string? EInvoiceNumber { get; set; }
public BasicProjectVM? Project { get; set; } public BasicProjectVM? Project { get; set; }
public BasicOrganizationVm? BilledTo { get; set; }
public DateTime InvoiceDate { get; set; } public DateTime InvoiceDate { get; set; }
public DateTime ClientSubmitedDate { get; set; } public DateTime ClientSubmitedDate { get; set; }
public DateTime ExceptedPaymentDate { get; set; } public DateTime ExceptedPaymentDate { get; set; }

View File

@ -1,4 +1,5 @@
using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.Projects;
namespace Marco.Pms.Model.ViewModels.Collection namespace Marco.Pms.Model.ViewModels.Collection
@ -10,6 +11,7 @@ namespace Marco.Pms.Model.ViewModels.Collection
public string Description { get; set; } = default!; public string Description { get; set; } = default!;
public string InvoiceNumber { get; set; } = default!; public string InvoiceNumber { get; set; } = default!;
public string? EInvoiceNumber { get; set; } public string? EInvoiceNumber { get; set; }
public BasicOrganizationVm? BilledTo { get; set; }
public BasicProjectVM? Project { get; set; } public BasicProjectVM? Project { get; set; }
public DateTime InvoiceDate { get; set; } public DateTime InvoiceDate { get; set; }
public DateTime ClientSubmitedDate { get; set; } public DateTime ClientSubmitedDate { get; set; }

View File

@ -2,21 +2,23 @@
using Marco.Pms.DataAccess.Data; using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers.Utility; using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.Collection; using Marco.Pms.Model.Collection;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Entitlements;
using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Collection;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Services.Service; using Marco.Pms.Services.Service;
using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service; using MarcoBMS.Services.Service;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MongoDB.Driver; using MongoDB.Driver;
using Document = Marco.Pms.Model.DocumentManager.Document;
namespace Marco.Pms.Services.Controllers namespace Marco.Pms.Services.Controllers
{ {
@ -50,185 +52,168 @@ namespace Marco.Pms.Services.Controllers
#region =================================================================== Get Functions =================================================================== #region =================================================================== Get Functions ===================================================================
[HttpGet("invoice/list")] /// <summary>
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 /// Fetches a paginated and filtered list of invoices after validating permissions.
, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) /// </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)
{ {
_logger.LogInfo( try
"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);
// Get the currently logged-in employee
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Log starting permission checks
_logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id);
// Initiate permission check tasks asynchronously
var adminPermissionTask = Task.Run(async () =>
{ {
using var scope = _serviceScopeFactory.CreateScope(); _logger.LogInfo(
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>(); "GetInvoiceListAsync called. Page: {PageNumber}, Size: {PageSize}, Active: {IsActive}, Pending: {IsPending}, Search: '{Search}', From: {From}, To: {To}",
return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); pageNumber, pageSize, isActive, isPending, searchString ?? string.Empty, fromDate ?? DateTime.UtcNow, toDate ?? DateTime.UtcNow);
});
var viewPermissionTask = Task.Run(async () => // Validate user identity and permissions in parallel for best performance
{ var employee = await _userHelper.GetCurrentEmployeeAsync();
using var scope = _serviceScopeFactory.CreateScope(); _logger.LogInfo("Performing permission checks for EmployeeId: {EmployeeId}", employee.Id);
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id);
});
var createPermissionTask = Task.Run(async () => var permissionTasks = new[]
{
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);
return StatusCode(403, ApiResponse<object>.ErrorResponse(
"Access Denied",
"User does not have permission to access collection data.",
403));
}
// Optionally log success or continue with further processing here
_logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id);
await using var _context = await _dbContextFactory.CreateDbContextAsync();
// Build base query with required includes and no tracking
var invoicesQuery = _context.Invoices
.Include(i => i.Project)
.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
// Apply date filter
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);
}
// Apply search filter
if (!string.IsNullOrWhiteSpace(searchString))
{
invoicesQuery = invoicesQuery.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
_logger.LogDebug("Applied search filter with term: {SearchString}", searchString);
}
// Apply project filter
if (projectId.HasValue)
{
invoicesQuery = invoicesQuery.Where(i => i.ProjectId == projectId.Value);
_logger.LogDebug("Applied project filter with term: {ProjectId}", projectId);
}
// Get total count before pagination
var totalEntites = await invoicesQuery.CountAsync();
_logger.LogDebug("Total matching invoices: {TotalCount}", totalEntites);
// Apply sorting and pagination
var invoices = await invoicesQuery
.OrderByDescending(i => i.InvoiceDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (!invoices.Any())
{
_logger.LogInfo("No invoices found for the given criteria.");
var emptyResponse = new
{ {
CurrentPage = pageNumber, HasPermissionAsync(PermissionsMaster.CollectionAdmin, employee.Id),
TotalPages = 0, HasPermissionAsync(PermissionsMaster.ViewCollection, employee.Id),
TotalEntites = 0, HasPermissionAsync(PermissionsMaster.CreateCollection, employee.Id),
Data = new List<InvoiceListVM>() HasPermissionAsync(PermissionsMaster.EditCollection, employee.Id),
HasPermissionAsync(PermissionsMaster.AddPayment, employee.Id)
}; };
return Ok(ApiResponse<object>.SuccessResponse(emptyResponse, "No invoices found"));
}
// Fetch all related payment data in a single query await Task.WhenAll(permissionTasks);
var invoiceIds = invoices.Select(i => i.Id).ToList();
var paymentGroups = await _context.ReceivedInvoicePayments if (permissionTasks.All(t => !t.Result))
.AsNoTracking()
.Where(rip => invoiceIds.Contains(rip.InvoiceId) && rip.TenantId == tenantId)
.GroupBy(rip => rip.InvoiceId)
.Select(g => new
{ {
InvoiceId = g.Key, _logger.LogWarning("Access denied. EmployeeId {EmployeeId} lacks relevant collection permissions.", employee.Id);
PaidAmount = g.Sum(rip => rip.Amount) return StatusCode(403, ApiResponse<object>.ErrorResponse(
}) "Access Denied",
.ToDictionaryAsync(x => x.InvoiceId, x => x.PaidAmount); "User does not have necessary permissions.",
403));
}
_logger.LogDebug("Fetched payment data for {Count} invoices", paymentGroups.Count); _logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id);
// Map and calculate balance in memory await using var _context = await _dbContextFactory.CreateDbContextAsync();
var results = new List<InvoiceListVM>(); await using var context = await _dbContextFactory.CreateDbContextAsync();
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 // Build invoice query efficiently - always use AsNoTracking for reads
if (isPending && (balanceAmount <= 0 || invoice.MarkAsCompleted)) var query = _context.Invoices
continue; .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);
var result = _mapper.Map<InvoiceListVM>(invoice); // Filter by date, ensuring date boundaries are correct
result.BalanceAmount = balanceAmount; if (fromDate.HasValue && toDate.HasValue)
results.Add(result); {
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);
}
if (!string.IsNullOrEmpty(searchString))
{
query = query.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
_logger.LogDebug("Search filter applied: '{Search}'", searchString);
}
if (projectId.HasValue)
{
query = query.Where(i => i.ProjectId == projectId.Value);
_logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value);
}
var totalItems = await query.CountAsync();
_logger.LogInfo("Total invoices found: {TotalItems}", totalItems);
var pagedInvoices = await query
.OrderByDescending(i => i.InvoiceDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (!pagedInvoices.Any())
{
_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 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(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("Received payment data for {Count} invoices.", paymentGroups.Count);
// 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 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 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)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)
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
var response = new
{ {
CurrentPage = pageNumber, // Centralized and structured error logging
TotalPages = totalPages, _logger.LogError(ex, "Error in GetInvoiceListAsync: {Message}", ex.Message);
TotalEntites = totalEntites,
Data = results
};
_logger.LogInfo("Successfully returned {ResultCount} invoices out of {TotalCount} total", results.Count, totalEntites); // Use standardized error response structure
return Ok(ApiResponse<object>.SuccessResponse(response, $"{results.Count} invoices fetched successfully")); return StatusCode(500, ApiResponse<object>.ErrorResponse(
"Internal Server Error",
"An unexpected error occurred while fetching invoices.",
500));
}
} }
/// <summary> /// <summary>
/// Retrieves complete details of a specific invoice including associated comments, attachments, and payments. /// Retrieves complete details of a specific invoice including associated comments, attachments, and payments.
/// </summary> /// </summary>
@ -313,7 +298,7 @@ namespace Marco.Pms.Services.Controllers
// Retrieve primary invoice details with related entities (project, created/updated by + roles) // Retrieve primary invoice details with related entities (project, created/updated by + roles)
var invoice = await context.Invoices var invoice = await context.Invoices
.Include(i => i.Project) .Include(i => i.BilledTo)
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking() .AsNoTracking()
@ -341,6 +326,35 @@ namespace Marco.Pms.Services.Controllers
// Map invoice to response view model // Map invoice to response view model
var response = _mapper.Map<InvoiceDetailsVM>(invoice); 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 // Populate related data
if (comments.Any()) if (comments.Any())
response.Comments = _mapper.Map<List<InvoiceCommentVM>>(comments); response.Comments = _mapper.Map<List<InvoiceCommentVM>>(comments);
@ -512,15 +526,57 @@ namespace Marco.Pms.Services.Controllers
// Fetch project // Fetch project
var project = await _context.Projects var infraProjectTask = Task.Run(async () =>
.FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); {
if (project == null) 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}", _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}",
model.ProjectId, tenantId); model.ProjectId, tenantId);
return NotFound(ApiResponse<object>.ErrorResponse("Project not found", "Project not found", 404)); 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 // Begin transaction scope with async flow support
await using var transaction = await _context.Database.BeginTransactionAsync(); await using var transaction = await _context.Database.BeginTransactionAsync();
var invoice = new Invoice(); var invoice = new Invoice();
@ -555,7 +611,7 @@ namespace Marco.Pms.Services.Controllers
var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileType = _s3Service.GetContentTypeFromBase64(base64);
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice"); 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); await _s3Service.UploadFileAsync(base64, fileType, objectKey);
@ -603,7 +659,8 @@ namespace Marco.Pms.Services.Controllers
// Build response // Build response
var response = _mapper.Map<InvoiceListVM>(invoice); 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.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.BalanceAmount = response.BasicAmount + response.TaxAmount; response.BalanceAmount = response.BasicAmount + response.TaxAmount;
@ -987,15 +1044,58 @@ namespace Marco.Pms.Services.Controllers
} }
// Fetch project // Fetch project
var project = await _context.Projects var infraProjectTask = Task.Run(async () =>
.FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); {
if (project == null) 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}", _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}",
model.ProjectId, tenantId); model.ProjectId, tenantId);
return NotFound(ApiResponse<object>.ErrorResponse("Project not found", "Project not found", 404)); 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 // Prevent modification if any payment has already been received
var receivedPaymentExists = await _context.ReceivedInvoicePayments var receivedPaymentExists = await _context.ReceivedInvoicePayments
.AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId); .AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId);
@ -1064,7 +1164,7 @@ namespace Marco.Pms.Services.Controllers
var contentType = _s3Service.GetContentTypeFromBase64(base64Data); var contentType = _s3Service.GetContentTypeFromBase64(base64Data);
var fileName = _s3Service.GenerateFileName(contentType, tenantId, "invoice"); 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 // Upload file to S3
await _s3Service.UploadFileAsync(base64Data, contentType, objectKey); await _s3Service.UploadFileAsync(base64Data, contentType, objectKey);
@ -1116,7 +1216,8 @@ namespace Marco.Pms.Services.Controllers
// Build response // Build response
var response = _mapper.Map<InvoiceListVM>(invoice); 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.UpdatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.BalanceAmount = response.BasicAmount + response.TaxAmount; response.BalanceAmount = response.BasicAmount + response.TaxAmount;
@ -1236,6 +1337,16 @@ namespace Marco.Pms.Services.Controllers
#region =================================================================== Helper Functions =================================================================== #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> /// <summary>
/// Loads invoice comments asynchronously with related metadata. /// Loads invoice comments asynchronously with related metadata.
/// </summary> /// </summary>

View File

@ -194,11 +194,17 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<ServiceProjectDto, ServiceProject>(); CreateMap<ServiceProjectDto, ServiceProject>();
CreateMap<ServiceProject, BasicProjectVM>();
CreateMap<ServiceProject, ServiceProjectVM>(); CreateMap<ServiceProject, ServiceProjectVM>();
CreateMap<ServiceProject, BasicServiceProjectVM>(); CreateMap<ServiceProject, BasicServiceProjectVM>();
CreateMap<ServiceProject, ServiceProjectDetailsVM>(); CreateMap<ServiceProject, ServiceProjectDetailsVM>();
CreateMap<ServiceProjectAllocation, ServiceProjectAllocationVM>(); CreateMap<ServiceProjectAllocation, ServiceProjectAllocationVM>();
//#region ======================================================= Talking Points =======================================================
//CreateMap<TalkingPointDto, TalkingPoint>();
//CreateMap<TalkingPoint, TalkingPointVM>();
//#endregion
#region ======================================================= Job Ticket ======================================================= #region ======================================================= Job Ticket =======================================================
CreateMap<CreateJobTicketDto, JobTicket>(); CreateMap<CreateJobTicketDto, JobTicket>();
CreateMap<UpdateJobTicketDto, JobTicket>(); CreateMap<UpdateJobTicketDto, JobTicket>();

View File

@ -40,8 +40,8 @@
"BucketName": "testenv-marco-pms-documents" "BucketName": "testenv-marco-pms-documents"
}, },
"MongoDB": { "MongoDB": {
"SerilogDatabaseUrl": "mongodb://localhost:27017/DotNetLogs", "SerilogDatabaseUrl": "mongodb://devuser:DevPass123@147.93.98.152:27017/DotNetLogsLocalDev?authSource=admin&eplicaSet=rs01&directConnection=true",
"ConnectionString": "mongodb://localhost:27017/MarcoBMS_Caches?socketTimeoutMS=500&serverSelectionTimeoutMS=500&connectTimeoutMS=500", "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" "ModificationConnectionString": "mongodb://devuser:DevPass123@147.93.98.152:27017/MarcoBMSLocalDev?authSource=admin&eplicaSet=rs01&directConnection=true"
}, },
"Razorpay": { "Razorpay": {