using Microsoft.EntityFrameworkCore; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; using ProposalSystem.Infrastructure.Data; namespace ProposalSystem.Infrastructure.Services; public class ProposalService : IProposalService { private readonly ProposalDbContext _db; private readonly ICurrentUserService _currentUser; private readonly IProposalNumberGenerator _numberGenerator; private readonly IAuditService _audit; private readonly IJobPublisher _jobPublisher; public ProposalService( ProposalDbContext db, ICurrentUserService currentUser, IProposalNumberGenerator numberGenerator, IAuditService audit, IJobPublisher jobPublisher) { _db = db; _currentUser = currentUser; _numberGenerator = numberGenerator; _audit = audit; _jobPublisher = jobPublisher; } public async Task CreateAsync(CreateProposalRequest request, CancellationToken ct = default) { var proposalNumber = await _numberGenerator.GenerateAsync(ct); var now = DateTime.UtcNow; var proposal = new Proposal { Id = Guid.NewGuid(), ProposalNumber = proposalNumber, WorkOrderNumber = request.WorkOrderNumber, CustomerName = request.CustomerName, CustomerAddress = request.CustomerAddress, ScopeOfWork = request.ScopeOfWork, ServiceCategory = request.ServiceCategory, Priority = request.Priority, Status = ProposalStatus.InReview, Notes = request.Notes ?? string.Empty, SubmittedById = _currentUser.UserId, SubmittedAt = now, CreatedAt = now, UpdatedAt = now, }; _db.Proposals.Add(proposal); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct); await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); return MapToResponse(proposal); } public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.SubmittedBy) .Include(p => p.AssignedAdmin) .Include(p => p.ApprovedBy) .FirstOrDefaultAsync(p => p.Id == id, ct); return proposal == null ? null : MapToResponse(proposal); } public async Task> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default) { var query = _db.Proposals .Include(p => p.SubmittedBy) .Include(p => p.AssignedAdmin) .AsQueryable(); if (filter.Mine) { query = query.Where(p => p.SubmittedById == _currentUser.UserId); } if (filter.Status.HasValue) query = query.Where(p => p.Status == filter.Status.Value); if (filter.ServiceCategory.HasValue) query = query.Where(p => p.ServiceCategory == filter.ServiceCategory.Value); if (filter.Priority.HasValue) query = query.Where(p => p.Priority == filter.Priority.Value); if (filter.FromDate.HasValue) query = query.Where(p => p.SubmittedAt >= filter.FromDate.Value); if (filter.ToDate.HasValue) query = query.Where(p => p.SubmittedAt <= filter.ToDate.Value); if (!string.IsNullOrWhiteSpace(filter.Search)) { var search = filter.Search.ToLower(); query = query.Where(p => p.CustomerName.ToLower().Contains(search) || p.ProposalNumber.ToLower().Contains(search) || p.WorkOrderNumber.ToLower().Contains(search)); } var totalCount = await query.CountAsync(ct); var items = await query .OrderByDescending(p => p.Priority) .ThenByDescending(p => p.SubmittedAt) .Skip((filter.Page - 1) * filter.PageSize) .Take(filter.PageSize) .Select(p => new ProposalListResponse( p.Id, p.ProposalNumber, p.CustomerName, p.WorkOrderNumber, p.ServiceCategory, p.Priority, p.Status, p.TotalBidAmount, p.SubmittedAt, p.SubmittedBy != null ? p.SubmittedBy.DisplayName : null, p.AssignedAdmin != null ? p.AssignedAdmin.DisplayName : null )) .ToListAsync(ct); return new PagedResponse(items, totalCount, filter.Page, filter.PageSize); } public async Task UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default) { var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); if (request.RefinedScope != null) proposal.RefinedScope = request.RefinedScope; if (request.Notes != null) proposal.Notes = request.Notes; if (request.AssignedAdminId.HasValue) proposal.AssignedAdminId = request.AssignedAdminId.Value; if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft && request.Status.Value == ProposalStatus.InReview) proposal.Status = request.Status.Value; proposal.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.Edit, id, null, ct); return MapToResponse(proposal); } public async Task ApproveAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.LineItems) .FirstOrDefaultAsync(p => p.Id == id, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); if (proposal.Status == ProposalStatus.Approved) return MapToResponse(proposal); if (proposal.Status != ProposalStatus.InReview) throw new InvalidOperationException("Only proposals in review can be approved"); if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0)) throw new InvalidOperationException("Cannot approve proposal without priced line items"); proposal.Status = ProposalStatus.Approved; proposal.ApprovedById = _currentUser.UserId; proposal.ApprovedAt = DateTime.UtcNow; proposal.TotalBidAmount = proposal.LineItems.Sum(li => li.TotalPrice); proposal.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.Approve, id, null, ct); return MapToResponse(proposal); } public async Task MarkSentAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); if (proposal.Status == ProposalStatus.Sent) return MapToResponse(proposal); if (proposal.Status != ProposalStatus.Approved) throw new InvalidOperationException("Only approved proposals can be marked as sent"); proposal.Status = ProposalStatus.Sent; proposal.SentAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.MarkSent, id, null, ct); await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct); return MapToResponse(proposal); } public async Task ReviseAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.LineItems) .FirstOrDefaultAsync(p => p.Id == id, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); if (proposal.Status == ProposalStatus.Revised) { var existingRevision = await _db.Proposals .FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct); if (existingRevision != null) return MapToResponse(existingRevision); } if (proposal.Status != ProposalStatus.Sent) throw new InvalidOperationException("Only sent proposals can be revised"); var revision = new Proposal { Id = Guid.NewGuid(), ProposalNumber = proposal.ProposalNumber, WorkOrderNumber = proposal.WorkOrderNumber, CustomerName = proposal.CustomerName, CustomerAddress = proposal.CustomerAddress, ScopeOfWork = proposal.ScopeOfWork, RefinedScope = proposal.RefinedScope, ServiceCategory = proposal.ServiceCategory, Priority = proposal.Priority, Status = ProposalStatus.InReview, Notes = proposal.Notes, SubmittedById = proposal.SubmittedById, SubmittedAt = proposal.SubmittedAt, AssignedAdminId = _currentUser.UserId, CurrentRevision = proposal.CurrentRevision + 1, ParentProposalId = proposal.Id, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; foreach (var li in proposal.LineItems) { revision.LineItems.Add(new LineItem { Id = Guid.NewGuid(), ProposalId = revision.Id, Description = li.Description, Quantity = li.Quantity, Unit = li.Unit, UnitPrice = li.UnitPrice, TotalPrice = li.TotalPrice, PricingMode = li.PricingMode, SortOrder = li.SortOrder, Source = li.Source, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); } proposal.Status = ProposalStatus.Revised; proposal.UpdatedAt = DateTime.UtcNow; _db.Proposals.Add(revision); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, $"Revised from {proposal.Id}", ct); return MapToResponse(revision); } public async Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default) { var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); var rootId = proposal.ParentProposalId ?? proposal.Id; var revisions = await _db.Proposals .Where(p => p.Id == rootId || p.ParentProposalId == rootId) .OrderBy(p => p.CurrentRevision) .ToListAsync(ct); return revisions.Select(MapToResponse).ToList(); } public async Task> GetAuditTrailAsync(Guid id, CancellationToken ct = default) { return await _db.AuditLogs .Include(a => a.User) .Where(a => a.ProposalId == id) .OrderByDescending(a => a.Timestamp) .Select(a => new AuditLogResponse( a.Id, a.ProposalId, a.UserId, a.User != null ? a.User.DisplayName : null, a.Action, a.Details, a.Timestamp, a.IpAddress )) .ToListAsync(ct); } public async Task GetStatsAsync(CancellationToken ct = default) { var userId = _currentUser.UserId; var counts = await _db.Proposals .Where(p => p.SubmittedById == userId) .GroupBy(_ => 1) .Select(g => new { Total = g.Count(), InReview = g.Count(p => p.Status == ProposalStatus.InReview), Approved = g.Count(p => p.Status == ProposalStatus.Approved), Sent = g.Count(p => p.Status == ProposalStatus.Sent), }) .FirstOrDefaultAsync(ct); return counts == null ? new ProposalStatsResponse(0, 0, 0, 0) : new ProposalStatsResponse(counts.Total, counts.InReview, counts.Approved, counts.Sent); } private static ProposalResponse MapToResponse(Proposal p) => new( p.Id, p.ProposalNumber, p.WorkOrderNumber, p.CustomerName, p.CustomerAddress, p.ScopeOfWork, p.RefinedScope, p.ServiceCategory, p.Priority, p.Status, p.TotalBidAmount, p.VendorTotalCost, p.Notes, p.SubmittedById, p.SubmittedBy?.DisplayName, p.SubmittedAt, p.AssignedAdminId, p.ApprovedById, p.ApprovedAt, p.SentAt, p.CurrentRevision, p.ParentProposalId, p.CreatedAt, p.UpdatedAt ); }