mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 20:33:14 +00:00
172 lines
5.7 KiB
C#
172 lines
5.7 KiB
C#
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using Microsoft.Extensions.Logging;
|
||
|
|
using ProposalSystem.Application.DTOs;
|
||
|
|
using ProposalSystem.Application.Interfaces;
|
||
|
|
using ProposalSystem.Domain.Entities;
|
||
|
|
using ProposalSystem.Infrastructure.Data;
|
||
|
|
|
||
|
|
namespace ProposalSystem.Infrastructure.Services;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// PR5: Pricing Library CRUD service. Publishes "library-ingest" SQS jobs
|
||
|
|
/// on create/update so the Bedrock KB corpus stays in sync.
|
||
|
|
/// </summary>
|
||
|
|
public class PricingLibraryService : IPricingLibraryService
|
||
|
|
{
|
||
|
|
private readonly ProposalDbContext _db;
|
||
|
|
private readonly IJobPublisher _jobPublisher;
|
||
|
|
private readonly ILogger<PricingLibraryService> _logger;
|
||
|
|
|
||
|
|
public PricingLibraryService(
|
||
|
|
ProposalDbContext db,
|
||
|
|
IJobPublisher jobPublisher,
|
||
|
|
ILogger<PricingLibraryService> logger)
|
||
|
|
{
|
||
|
|
_db = db;
|
||
|
|
_jobPublisher = jobPublisher;
|
||
|
|
_logger = logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PagedResponse<PricingLibraryItemResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
page = Math.Max(1, page);
|
||
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||
|
|
|
||
|
|
var query = _db.PricingLibraryItems.AsNoTracking().AsQueryable();
|
||
|
|
|
||
|
|
var totalCount = await query.CountAsync(ct);
|
||
|
|
|
||
|
|
var items = await query
|
||
|
|
.OrderBy(i => i.Title)
|
||
|
|
.Skip((page - 1) * pageSize)
|
||
|
|
.Take(pageSize)
|
||
|
|
.ToListAsync(ct);
|
||
|
|
|
||
|
|
return new PagedResponse<PricingLibraryItemResponse>(
|
||
|
|
items.Select(MapToResponse).ToList(),
|
||
|
|
totalCount,
|
||
|
|
page,
|
||
|
|
pageSize
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PricingLibraryItemResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var item = await _db.PricingLibraryItems
|
||
|
|
.AsNoTracking()
|
||
|
|
.FirstOrDefaultAsync(i => i.Id == id, ct);
|
||
|
|
|
||
|
|
return item == null ? null : MapToResponse(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PricingLibraryItemResponse> CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var item = new PricingLibraryItem
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
Title = request.Title,
|
||
|
|
Description = request.Description,
|
||
|
|
ServiceCategory = request.ServiceCategory,
|
||
|
|
Unit = request.Unit,
|
||
|
|
UnitPrice = request.UnitPrice,
|
||
|
|
Keywords = request.Keywords,
|
||
|
|
Source = request.Source ?? "Manual",
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
_db.PricingLibraryItems.Add(item);
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
_logger.LogInformation("PricingLibraryItem {ItemId} created with title '{Title}'",
|
||
|
|
item.Id, item.Title);
|
||
|
|
|
||
|
|
// PR5: Trigger KB ingestion so the Bedrock RAG corpus picks up the new item.
|
||
|
|
// Wrap in try/catch so a publish failure does not roll back the save
|
||
|
|
// (matches the audit-no-rollback convention from API-H6).
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id);
|
||
|
|
}
|
||
|
|
|
||
|
|
return MapToResponse(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PricingLibraryItemResponse?> UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct);
|
||
|
|
if (item == null) return null;
|
||
|
|
|
||
|
|
if (request.Title != null)
|
||
|
|
item.Title = request.Title;
|
||
|
|
|
||
|
|
if (request.Description != null)
|
||
|
|
item.Description = request.Description;
|
||
|
|
|
||
|
|
if (request.ServiceCategory.HasValue)
|
||
|
|
item.ServiceCategory = request.ServiceCategory.Value;
|
||
|
|
|
||
|
|
if (request.Unit != null)
|
||
|
|
item.Unit = request.Unit;
|
||
|
|
|
||
|
|
if (request.UnitPrice.HasValue)
|
||
|
|
item.UnitPrice = request.UnitPrice.Value;
|
||
|
|
|
||
|
|
if (request.Keywords != null)
|
||
|
|
item.Keywords = request.Keywords;
|
||
|
|
|
||
|
|
item.UpdatedAt = DateTime.UtcNow;
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
_logger.LogInformation("PricingLibraryItem {ItemId} updated", item.Id);
|
||
|
|
|
||
|
|
// PR5: Trigger KB re-ingestion so the Bedrock RAG corpus reflects the update.
|
||
|
|
// Wrap in try/catch so a publish failure does not roll back the save
|
||
|
|
// (matches the audit-no-rollback convention from API-H6).
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id);
|
||
|
|
}
|
||
|
|
|
||
|
|
return MapToResponse(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||
|
|
{
|
||
|
|
var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct);
|
||
|
|
if (item == null) return false;
|
||
|
|
|
||
|
|
_db.PricingLibraryItems.Remove(item);
|
||
|
|
await _db.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
_logger.LogInformation("PricingLibraryItem {ItemId} deleted", item.Id);
|
||
|
|
|
||
|
|
// TODO: PR5 follow-up — remove the corresponding markdown document from the
|
||
|
|
// Bedrock Knowledge Base S3 bucket so stale items are not returned by RAG queries.
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static PricingLibraryItemResponse MapToResponse(PricingLibraryItem item) => new(
|
||
|
|
item.Id,
|
||
|
|
item.Title,
|
||
|
|
item.Description,
|
||
|
|
item.ServiceCategory,
|
||
|
|
item.Unit,
|
||
|
|
item.UnitPrice,
|
||
|
|
item.Keywords,
|
||
|
|
item.Source,
|
||
|
|
item.CreatedAt,
|
||
|
|
item.UpdatedAt
|
||
|
|
);
|
||
|
|
}
|