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")
.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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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": {