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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user