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:
@@ -0,0 +1,174 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Company;
|
||||
|
||||
// ============================================================================
|
||||
// LIST DTO - For Company Settings tab table
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateListDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public int FieldCount { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FULL DTO - For Edit modal and formula evaluation
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREATE DTO
|
||||
// ============================================================================
|
||||
public class CreateCustomItemTemplateDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE DTO
|
||||
// ============================================================================
|
||||
public class UpdateCustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
|
||||
// ============================================================================
|
||||
public class CustomItemTemplatePickerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI GENERATION DTOs
|
||||
// ============================================================================
|
||||
public class GenerateFormulaFromAiRequest
|
||||
{
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GenerateFormulaFromAiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? OutputMode { get; set; }
|
||||
public string? FieldsJson { get; set; }
|
||||
public string? Formula { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
|
||||
/// <summary>Result of running the formula with any sample values found in the description.</summary>
|
||||
public decimal? VerificationResult { get; set; }
|
||||
public string? VerificationInputs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FORMULA EVALUATION DTOs
|
||||
// ============================================================================
|
||||
public class EvaluateFormulaRequest
|
||||
{
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
|
||||
[Required]
|
||||
public string VariablesJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public class EvaluateFormulaResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public decimal? Result { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -325,7 +325,11 @@ public class JobItemDto
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public bool IsAiItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -475,6 +475,11 @@ public class QuoteItemDto
|
||||
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// Custom formula item
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Cost breakdown snapshot
|
||||
public decimal ItemMaterialCost { get; set; }
|
||||
public decimal ItemLaborCost { get; set; }
|
||||
@@ -559,6 +564,11 @@ public class CreateQuoteItemDto
|
||||
|
||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||
public int? AiPredictionId { get; set; }
|
||||
|
||||
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface ICustomFormulaAiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a NCalc formula, field list, and notes from a natural-language description
|
||||
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
|
||||
/// ready to pre-fill the template editor.
|
||||
/// </summary>
|
||||
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||
GenerateFormulaFromAiRequest request,
|
||||
byte[]? imageBytes = null,
|
||||
string? imageContentType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
|
||||
/// Safe server-side only — no user-controlled code execution.
|
||||
/// </summary>
|
||||
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class CustomItemTemplateProfile : Profile
|
||||
{
|
||||
public CustomItemTemplateProfile()
|
||||
{
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
|
||||
.ForMember(dest => dest.FieldCount,
|
||||
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
|
||||
|
||||
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
|
||||
|
||||
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
|
||||
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
|
||||
|
||||
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
|
||||
}
|
||||
|
||||
private static int CountFields(string fieldsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? doc.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
||||
.ReverseMap()
|
||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||
|
||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
public bool IsCustomFormulaItem { get; init; }
|
||||
public int? CustomItemTemplateId { get; init; }
|
||||
public string? FormulaFieldValuesJson { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
|
||||
@@ -288,6 +288,24 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
};
|
||||
}
|
||||
|
||||
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||
// and stored the result as ManualUnitPrice. Use it directly — no coating math.
|
||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
||||
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var total = item.ManualUnitPrice.Value * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = total,
|
||||
UnitPrice = item.ManualUnitPrice.Value,
|
||||
TotalPrice = total
|
||||
};
|
||||
}
|
||||
|
||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
|
||||
@@ -130,6 +130,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
@@ -243,6 +251,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user