mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 19:23:15 +00:00
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.
API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
wrapped so a publish failure never rolls back the save.
Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
warns when both present.
Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.
GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
171 lines
5.7 KiB
C#
171 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
|
|
);
|
|
}
|