Add Custom Formula Item Templates with AI generation and wizard integration

Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:09:22 -04:00
parent e443457139
commit 1eba50cf0f
40 changed files with 12846 additions and 33 deletions
@@ -33,6 +33,8 @@ public class CompanySettingsController : Controller
private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
public CompanySettingsController(
IUnitOfWork unitOfWork,
@@ -45,7 +47,9 @@ public class CompanySettingsController : Controller
IConfiguration configuration,
IAuditLogService auditLog,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
SignInManager<ApplicationUser> signInManager,
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -58,6 +62,8 @@ public class CompanySettingsController : Controller
_auditLog = auditLog;
_userManager = userManager;
_signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
}
/// <summary>
@@ -2962,6 +2968,174 @@ public class CompanySettingsController : Controller
return RedirectToAction(nameof(DeleteAccount));
}
}
// ─── Custom Formula Item Templates ──────────────────────────────────────────
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
return Json(new { success = true, template = dto });
}
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplates()
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId);
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
return Json(new { success = true, templates = dtos });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, id = entity.Id });
}
/// <summary>Updates an existing formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>
/// Uploads a diagram image for a template to blob storage container
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
/// Returns the blob path for storage on the entity.
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
if (diagramFile.Length > 10 * 1024 * 1024)
return Json(new { success = false, message = "Image must be under 10 MB." });
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
using var stream = diagramFile.OpenReadStream();
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
if (!ok)
return Json(new { success = false, message = err });
entity.DiagramImagePath = blobPath;
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true, diagramImagePath = blobPath });
}
/// <summary>
/// Serves a template diagram image from blob storage. The path is tenant-scoped
/// so cross-company access is prevented by checking CompanyId on the template.
/// </summary>
[HttpGet]
public async Task<IActionResult> TemplateDiagram(int templateId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
return NotFound();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Evaluates a NCalc formula with the supplied variable values.
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
/// in the Application/Infrastructure layer.
/// </summary>
[HttpPost]
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
{
var result = _formulaAiService.EvaluateFormula(req);
return Json(result);
}
/// <summary>
/// Calls Claude to generate a formula template from a natural-language description
/// and an optional diagram image uploaded in the same multipart form.
/// </summary>
[HttpPost]
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
{
if (string.IsNullOrWhiteSpace(description))
return Json(new { success = false, error = "Description is required." });
byte[]? imageBytes = null;
string? imageContentType = null;
if (diagramImage is { Length: > 0 })
{
using var ms = new MemoryStream();
await diagramImage.CopyToAsync(ms);
imageBytes = ms.ToArray();
imageContentType = diagramImage.ContentType;
}
var result = await _formulaAiService.GenerateFormulaAsync(
new GenerateFormulaFromAiRequest { Description = description },
imageBytes,
imageContentType);
return Json(result);
}
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);