Initial commit
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Anthropic.SDK;
|
||||
using Anthropic.SDK.Messaging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public class AiQuoteService : IAiQuoteService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AiQuoteService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// The core system prompt that defines Claude's role as a powder coating estimator.
|
||||
/// It specifies the exact JSON schema the model must return, complexity definitions,
|
||||
/// surface-area estimation rules, and material-specific active-labor guidance for cast
|
||||
/// iron, cast aluminum, galvanized steel, and heavy steel. The prompt deliberately
|
||||
/// excludes oven dwell time from <c>estimatedMinutes</c> because cure time is added at
|
||||
/// the quote level to avoid double-charging when multiple items share the same oven cycle.
|
||||
/// Material-specific preheat/outgassing handling is separated into <c>requiresPreheat</c>
|
||||
/// and <c>preheatMinutes</c> fields so the pricing engine can charge a distinct extra oven
|
||||
/// cost only for materials that truly need it. The tags list is a closed enumeration to
|
||||
/// prevent free-text values that would break tag-based filtering in future reporting.
|
||||
/// </summary>
|
||||
private const string BaseSystemPrompt = @"You are an expert powder coating estimator. Your job is to analyze photos of items to be powder coated and provide accurate estimates.
|
||||
|
||||
When analyzing images, you must respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation, just the JSON:
|
||||
|
||||
{
|
||||
""description"": ""string - concise item name (e.g., 'Steel bracket', 'Aluminum wheel rim', '5-piece railing section')"",
|
||||
""surfaceAreaSqFt"": number - estimated total surface area in square feet per single item (use the reference dimension provided),
|
||||
""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
|
||||
""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per single item covering: sandblasting/media blasting, chemical stripping (if needed), masking, racking/hanging, powder application, unracking, and inspection. DO NOT include oven cure/dwell time — that is priced separately. Only count minutes where a worker is actively doing something.
|
||||
""requiresPreheat"": boolean - true if this material needs a dedicated outgassing or preheat oven cycle BEFORE coating (cast iron, cast aluminum, galvanized steel, wrought iron). False for standard steel, aluminum sheet/extrusion, stainless.
|
||||
""preheatMinutes"": number - duration of the preheat/outgassing oven cycle in minutes. Set to 0 if requiresPreheat is false. Typical values: cast iron 45-60, cast aluminum 30-45, galvanized 30-45.
|
||||
""confidence"": ""Low"" | ""Medium"" | ""High"",
|
||||
""needsFollowUp"": boolean,
|
||||
""followUpQuestion"": ""string or null"",
|
||||
""reasoning"": ""string - brief explanation of your estimate"",
|
||||
""tags"": [""string""] - 1 to 3 tags from ONLY this fixed list: automotive, railing, furniture, structural, flat-panel, tubular, ornamental, architectural, industrial, agricultural, equipment, enclosure, signage, bracket, gate, wheel, frame, sheet-metal
|
||||
}
|
||||
|
||||
Complexity guide (affects both prep difficulty and coating time):
|
||||
- Simple: flat panels, basic shapes, easy access to all surfaces, minimal masking needed
|
||||
- Moderate: moderate curves, some recessed areas, standard brackets, light masking
|
||||
- Complex: intricate geometry, deep recesses, welded assemblies, many surfaces, significant masking or stripping needed
|
||||
- Extreme: highly ornate, very deep cavities, extreme surface variation, heavy prep and masking required
|
||||
|
||||
Surface area estimation:
|
||||
- Use the reference dimension provided to calibrate your estimate
|
||||
- Account for ALL surfaces that need coating (front, back, sides, edges)
|
||||
- For hollow items (tubes, pipes), include interior only if specified
|
||||
- Express as sq ft per single item
|
||||
|
||||
MATERIAL-SPECIFIC CONSIDERATIONS — these directly affect estimatedMinutes and complexity:
|
||||
|
||||
Cast Iron (active labor only — exclude oven dwell):
|
||||
- Outgassing preheat setup: worker loads piece into oven, waits for outgassing, unloads — count ~10 min active handling, NOT the full oven cycle
|
||||
- Porous surface needs aggressive sandblasting/media blasting — add 15-30 min depending on size
|
||||
- Heavy pieces (30+ lbs) require two-person handling for racking/unracking — add 10-15 min vs lighter items
|
||||
- Minimum complexity for cast iron is Moderate; anything with relief detail, fins, or recesses is Complex or Extreme
|
||||
- Typical active labor for cast iron: 35-70 min depending on size and detail
|
||||
|
||||
Heavy Steel (30+ lbs, active labor only):
|
||||
- Two-person handling for racking/unracking — add 10-15 min vs lighter items
|
||||
- Heavier blasting/prep required for thick sections
|
||||
|
||||
Cast Aluminum (active labor only):
|
||||
- Outgassing setup similar to cast iron — add 10 min active handling
|
||||
- Chemical conversion coat or etch primer often needed — add 15-20 min if applicable
|
||||
|
||||
Galvanized Steel (active labor only):
|
||||
- Must be chemically stripped or heavily blasted to ensure adhesion — add 20-30 min prep
|
||||
|
||||
If the user mentions the item is heavy, large, cast iron, or galvanized, ensure estimatedMinutes reflects the extra active prep and handling time for that material.
|
||||
|
||||
If you need clarification to give a good estimate, set needsFollowUp=true and provide one specific, answerable question in followUpQuestion.
|
||||
Only ask follow-up questions if truly needed — prefer to make reasonable assumptions and note them in reasoning.";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-request system prompt by appending company-specific context to
|
||||
/// <see cref="BaseSystemPrompt"/>. If no context is provided, or if both the profile
|
||||
/// text and accepted-example list are empty, the base prompt is returned as-is to avoid
|
||||
/// unnecessary string allocation. When context is present, the company's
|
||||
/// <c>AiContextProfile</c> free-text block (from the AI Profile tab in Company Settings)
|
||||
/// is appended first, followed by a few-shot calibration section listing previously
|
||||
/// accepted quote items with their actual surface area, complexity, minutes, and unit
|
||||
/// price. This two-layer approach lets shop owners nudge the AI toward their shop's
|
||||
/// real-world pricing without modifying the core prompt — the profile text captures
|
||||
/// qualitative rules ("we always charge a minimum of 20 min for racking") while the
|
||||
/// few-shot examples capture quantitative calibration from accepted quotes.
|
||||
/// </summary>
|
||||
private static string BuildSystemPrompt(CompanyAiContext? context)
|
||||
{
|
||||
if (context == null ||
|
||||
(string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
|
||||
return BaseSystemPrompt;
|
||||
|
||||
var sb = new StringBuilder(BaseSystemPrompt);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.ProfileText))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate your estimates for this particular shop:");
|
||||
sb.AppendLine(context.ProfileText.Trim());
|
||||
}
|
||||
|
||||
if (context.AcceptedExamples.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("CALIBRATION EXAMPLES — items this company has previously priced and accepted:");
|
||||
sb.AppendLine("Use these as reference points to calibrate your surface area, complexity, time, and pricing estimates.");
|
||||
foreach (var ex in context.AcceptedExamples)
|
||||
{
|
||||
var tags = string.IsNullOrWhiteSpace(ex.Tags) ? "" : $" [{ex.Tags}]";
|
||||
sb.AppendLine($"- {ex.Description}{tags}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, priced at ${ex.FinalUnitPrice:F2}/unit");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="AiQuoteService"/>. The Anthropic API key is
|
||||
/// read per call (not cached at construction time) so that configuration updates take
|
||||
/// effect without restarting the application.
|
||||
/// </summary>
|
||||
public AiQuoteService(IConfiguration config, ILogger<AiQuoteService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes one or more photos of an item to be powder coated and returns an estimated
|
||||
/// surface area, complexity, active labor minutes, pricing breakdown, and optional
|
||||
/// follow-up question. Supports up to two conversation rounds: on the first call the
|
||||
/// photos are embedded as base64 image content blocks alongside the user prompt; on
|
||||
/// subsequent calls the prior assistant and user turns are replayed from
|
||||
/// <see cref="AiAnalyzeItemRequest.ConversationHistory"/> so Claude retains context
|
||||
/// without the photos being re-uploaded. The round cap of 2 is enforced client-side
|
||||
/// by forcing <c>NeedsFollowUp = false</c> on round 2 regardless of what Claude returns,
|
||||
/// preventing an infinite loop if the model keeps asking questions. Pricing is calculated
|
||||
/// locally in <see cref="CalculatePricingPreview"/> using the company's
|
||||
/// <see cref="CompanyOperatingCosts"/> rather than asking Claude to price the item,
|
||||
/// ensuring the estimate is always consistent with the company's configured rates.
|
||||
/// </summary>
|
||||
public async Task<AiAnalyzeItemResult> AnalyzeItemAsync(
|
||||
AiAnalyzeItemRequest request,
|
||||
List<(byte[] Data, string ContentType, string FileName)> photos,
|
||||
CompanyOperatingCosts costs,
|
||||
decimal avgPowderCostPerLb,
|
||||
CompanyAiContext? context = null,
|
||||
CompanyBlastSetup? selectedBlastSetup = null)
|
||||
{
|
||||
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||
{
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = new AnthropicClient(apiKey);
|
||||
|
||||
// Build the conversation messages
|
||||
var messages = new List<Message>();
|
||||
|
||||
// Add prior conversation history for follow-up rounds
|
||||
if (request.ConversationHistory?.Count > 0)
|
||||
{
|
||||
foreach (var turn in request.ConversationHistory)
|
||||
{
|
||||
messages.Add(new Message
|
||||
{
|
||||
Role = turn.Role == "assistant" ? RoleType.Assistant : RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = turn.Content } }
|
||||
});
|
||||
}
|
||||
// Add follow-up answer as latest user message
|
||||
if (!string.IsNullOrWhiteSpace(request.FollowUpAnswer))
|
||||
{
|
||||
messages.Add(new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase>
|
||||
{
|
||||
new TextContent
|
||||
{
|
||||
Text = $"Answer to your question: {request.FollowUpAnswer}\n\nPlease now provide your final estimate as JSON."
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// First call — build the initial user message with photos + context
|
||||
var contentParts = new List<ContentBase>();
|
||||
|
||||
// Attach each photo as base64 image
|
||||
foreach (var (data, contentType, fileName) in photos)
|
||||
{
|
||||
var mediaType = contentType.ToLowerInvariant() switch
|
||||
{
|
||||
"image/jpeg" => "image/jpeg",
|
||||
"image/jpg" => "image/jpeg",
|
||||
"image/png" => "image/png",
|
||||
"image/gif" => "image/gif",
|
||||
"image/webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
|
||||
contentParts.Add(new ImageContent
|
||||
{
|
||||
Source = new ImageSource
|
||||
{
|
||||
MediaType = mediaType,
|
||||
Data = Convert.ToBase64String(data)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var userText = BuildUserPrompt(request, costs, avgPowderCostPerLb, selectedBlastSetup);
|
||||
contentParts.Add(new TextContent { Text = userText });
|
||||
|
||||
messages.Add(new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = contentParts
|
||||
});
|
||||
}
|
||||
|
||||
var messageRequest = new MessageParameters
|
||||
{
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxTokens = 1024,
|
||||
// Low temperature for deterministic estimation — the prompt already constrains
|
||||
// the model with exact blast/coating rates, so creativity adds noise, not value.
|
||||
Temperature = 0.2m,
|
||||
SystemMessage = BuildSystemPrompt(context),
|
||||
Messages = messages
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
|
||||
_logger.LogInformation("Claude AI response for quote analysis: {Response}", rawText.Length > 500 ? rawText[..500] : rawText);
|
||||
|
||||
// Parse JSON response
|
||||
var claudeResult = ParseClaudeResponse(rawText);
|
||||
if (claudeResult == null)
|
||||
{
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "AI returned an unexpected response format. Please try again."
|
||||
};
|
||||
}
|
||||
|
||||
// Determine follow-up round
|
||||
var currentRound = request.ConversationHistory?.Count > 0 ? 2 : 1;
|
||||
|
||||
// Build updated conversation history
|
||||
var newHistory = new List<AiConversationTurn>(request.ConversationHistory ?? new List<AiConversationTurn>());
|
||||
if (request.FollowUpAnswer != null)
|
||||
newHistory.Add(new AiConversationTurn { Role = "user", Content = $"Answer: {request.FollowUpAnswer}" });
|
||||
newHistory.Add(new AiConversationTurn { Role = "assistant", Content = rawText });
|
||||
|
||||
// If AI wants a follow-up but we've already done 2 rounds, force completion
|
||||
var needsFollowUp = claudeResult.NeedsFollowUp && currentRound < 2;
|
||||
|
||||
if (needsFollowUp)
|
||||
{
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = true,
|
||||
NeedsFollowUp = true,
|
||||
FollowUpQuestion = claudeResult.FollowUpQuestion,
|
||||
FollowUpRound = currentRound,
|
||||
ConversationHistory = newHistory
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate pricing preview using operating costs
|
||||
var (unitPrice, total, breakdown) = CalculatePricingPreview(claudeResult, request, costs, avgPowderCostPerLb);
|
||||
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = true,
|
||||
NeedsFollowUp = false,
|
||||
Description = claudeResult.Description,
|
||||
SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
|
||||
Complexity = claudeResult.Complexity,
|
||||
EstimatedMinutes = claudeResult.EstimatedMinutes,
|
||||
AiReasoning = claudeResult.Reasoning,
|
||||
Confidence = claudeResult.Confidence,
|
||||
EstimatedUnitPrice = unitPrice,
|
||||
EstimatedTotal = total,
|
||||
PowderCostPerLb = avgPowderCostPerLb,
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
ConversationHistory = newHistory,
|
||||
Tags = claudeResult.Tags,
|
||||
Breakdown = breakdown
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI request timed out after 60 seconds (quote analysis)");
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "The AI service did not respond in time. Please try again."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calling Claude AI for quote analysis");
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "The AI service encountered an error. Please try again."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the initial user message that accompanies the photo content blocks on the first
|
||||
/// analysis round. It injects the reference dimension, material type, estimated weight,
|
||||
/// quantity, desired finish, and coat count so Claude can calibrate surface area and active
|
||||
/// labor time without asking follow-up questions for common inputs. The company's labor
|
||||
/// rate and complexity multipliers are included so Claude can ground its reasoning in real
|
||||
/// shop costs, even though final pricing is computed locally by
|
||||
/// <see cref="CalculatePricingPreview"/>. The weight line is conditionally included only
|
||||
/// when a weight is provided, because mentioning "not provided" for weight prompts Claude
|
||||
/// to ask about it, which wastes a follow-up round for items where weight is irrelevant.
|
||||
/// </summary>
|
||||
private static string BuildUserPrompt(AiAnalyzeItemRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb, CompanyBlastSetup? selectedBlastSetup = null)
|
||||
{
|
||||
var materialLine = string.IsNullOrWhiteSpace(request.MaterialType)
|
||||
? "- Material type: Unknown (infer from photo if possible)"
|
||||
: $"- Material type: {request.MaterialType}";
|
||||
|
||||
var weightLine = request.EstimatedWeightLbs.HasValue && request.EstimatedWeightLbs > 0
|
||||
? $"- Estimated weight: {request.EstimatedWeightLbs:F0} lbs per piece — factor in heavy-handling time and extended heat-soak/cure accordingly"
|
||||
: "- Estimated weight: Not provided";
|
||||
|
||||
// Use the explicitly selected blast setup when provided; fall back to company-level costs.
|
||||
decimal blastRate;
|
||||
string blastSetupLabel;
|
||||
if (selectedBlastSetup != null)
|
||||
{
|
||||
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(selectedBlastSetup);
|
||||
blastSetupLabel = $" ({selectedBlastSetup.Name})";
|
||||
}
|
||||
else
|
||||
{
|
||||
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
||||
blastSetupLabel = string.Empty;
|
||||
}
|
||||
|
||||
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
|
||||
|
||||
// Build a shop-speed context line only when the shop has calibrated their equipment.
|
||||
// An uncalibrated shop (CFM = 0, no override) gets a generic instruction so the AI
|
||||
// falls back to industry-average times rather than anchoring on a misleading zero.
|
||||
string shopSpeedLine;
|
||||
if (blastRate > 0)
|
||||
{
|
||||
shopSpeedLine = $"- THIS SHOP'S blast rate{blastSetupLabel}: ~{blastRate:F0} sqft/hr — use this to derive sandblasting time (surface area ÷ blast rate), NOT generic industry averages";
|
||||
}
|
||||
else
|
||||
{
|
||||
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
||||
}
|
||||
|
||||
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
|
||||
|
||||
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
||||
|
||||
User-provided context:
|
||||
- Reference dimension: {request.ReferenceDimension}
|
||||
{materialLine}
|
||||
{weightLine}
|
||||
- Quantity to be coated: {request.Quantity} piece(s)
|
||||
- Desired color/finish: {request.DesiredColor}
|
||||
- Number of coating stages: {request.CoatCount}
|
||||
|
||||
Company operating costs for your reference:
|
||||
- Labor rate: ${costs.StandardLaborRate}/hr
|
||||
- Complexity multipliers — Simple: +{costs.ComplexitySimplePercent}%, Moderate: +{costs.ComplexityModeratePercent}%, Complex: +{costs.ComplexityComplexPercent}%, Extreme: +{costs.ComplexityExtremePercent}%
|
||||
{shopSpeedLine}
|
||||
{coatingSpeedLine}
|
||||
|
||||
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
|
||||
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
||||
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
||||
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
||||
Do NOT include oven cure/dwell — that is priced separately.
|
||||
Estimate the surface area for ONE item. I will multiply by quantity later.
|
||||
Respond with the JSON object only.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Claude's raw text response into a <see cref="ClaudeQuoteResponse"/> by stripping
|
||||
/// any surrounding markdown code fences and deserializing the JSON payload. Returns null
|
||||
/// (rather than throwing) when parsing fails so that <see cref="AnalyzeItemAsync"/> can
|
||||
/// return a structured error result instead of an unhandled exception. The fence-stripping
|
||||
/// logic searches for the first <c>{</c> and last <c>}</c> to handle cases where Claude
|
||||
/// includes a language tag on the fence (e.g., <c>```json</c>).
|
||||
/// </summary>
|
||||
private static ClaudeQuoteResponse? ParseClaudeResponse(string rawText)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Strip any markdown code fences
|
||||
var json = rawText.Trim();
|
||||
if (json.StartsWith("```"))
|
||||
{
|
||||
var start = json.IndexOf('{');
|
||||
var end = json.LastIndexOf('}');
|
||||
if (start >= 0 && end > start)
|
||||
json = json[start..(end + 1)];
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
return JsonSerializer.Deserialize<ClaudeQuoteResponse>(json, options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates Claude's estimated surface area, complexity, and active-labor minutes into
|
||||
/// a concrete unit price using the company's configured operating costs. Key design decisions:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Powder consumption uses fixed defaults (30 sq ft/lb coverage, 65% transfer
|
||||
/// efficiency) because most shops have not entered per-powder efficiency values, and the
|
||||
/// defaults represent industry averages for electrostatic spray application.</item>
|
||||
/// <item>Material minimum-minute floors are applied after dividing Claude's total batch
|
||||
/// minutes by quantity. This prevents the AI from returning, for example, 20 total minutes
|
||||
/// for a batch of 5 cast-iron pieces when each piece realistically requires at least 90
|
||||
/// active minutes of preheat setup, blasting, and handling.</item>
|
||||
/// <item>The standard oven cure cycle is not embedded in the unit price because it is
|
||||
/// priced at the quote level to avoid double-charging when multiple items share the same
|
||||
/// oven batch. <c>OvenCost</c> is therefore always 0 in the breakdown returned here.</item>
|
||||
/// <item>Preheat/outgassing cost is the exception: it is an EXTRA oven cycle unique to
|
||||
/// this item (cast iron, cast aluminum, galvanized), so it IS included in the unit price.</item>
|
||||
/// <item>General markup applies only to material costs (powder + consumables), not to
|
||||
/// labor or oven rates, because labor and oven rates are already set as billable rates.</item>
|
||||
/// <item>Complexity multiplier is applied to the entire pre-complexity subtotal
|
||||
/// (materials + labor + preheat) rather than just materials, reflecting the real-world
|
||||
/// cost of complex work: more masking material, higher risk of rework, slower throughput.</item>
|
||||
/// <item>Only computed dollar amounts are included in the breakdown — rates and percentages
|
||||
/// are omitted to avoid exposing proprietary pricing configuration to the client.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricingPreview(
|
||||
ClaudeQuoteResponse aiResult,
|
||||
AiAnalyzeItemRequest request,
|
||||
CompanyOperatingCosts costs,
|
||||
decimal avgPowderCostPerLb)
|
||||
{
|
||||
// Material cost: powder per coat
|
||||
const decimal defaultCoverage = 30m; // sq ft/lb
|
||||
const decimal defaultEfficiency = 0.65m; // 65%
|
||||
var lbsPerCoat = aiResult.SurfaceAreaSqFt > 0
|
||||
? aiResult.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
|
||||
: 0m;
|
||||
var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
|
||||
var consumablesSurcharge = materialCost * 0.05m;
|
||||
|
||||
// Material-type minimum time floors — safety net for AI underestimates.
|
||||
// These reflect the bare minimum realistic time for each material regardless of what the AI returns.
|
||||
// Active-labor-only floors (oven dwell excluded from estimatedMinutes per prompt instructions).
|
||||
var materialMinMinutes = (request.MaterialType ?? "").ToLowerInvariant() switch
|
||||
{
|
||||
var m when m.Contains("cast iron") => 90,
|
||||
var m when m.Contains("cast aluminum") => 70,
|
||||
var m when m.Contains("galvanized") => 50,
|
||||
var m when m.Contains("heavy steel") => 40,
|
||||
var m when m.Contains("wrought iron") => 75,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
|
||||
// The unit price × quantity must equal the total batch labor cost.
|
||||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
||||
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
||||
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
||||
var laborHours = perItemMinutes / 60m;
|
||||
var laborCost = laborHours * costs.StandardLaborRate;
|
||||
|
||||
// Standard cure cycle is handled at the quote level — not embedded here to avoid double-charging.
|
||||
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
|
||||
|
||||
// Material-specific preheat/outgassing cycle — this is an EXTRA oven cycle beyond the standard cure,
|
||||
// charged at the oven operating rate (not labor rate) directly to this item.
|
||||
var preheatCost = 0m;
|
||||
var preheatMinutes = 0;
|
||||
if (aiResult.RequiresPreheat && aiResult.PreheatMinutes > 0)
|
||||
{
|
||||
preheatMinutes = aiResult.PreheatMinutes;
|
||||
preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
|
||||
}
|
||||
|
||||
// Markup applies to materials only — labor and oven rates are already set as billable rates.
|
||||
var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
|
||||
|
||||
var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
|
||||
|
||||
// Complexity multiplier
|
||||
var complexityPct = aiResult.Complexity switch
|
||||
{
|
||||
"Simple" => costs.ComplexitySimplePercent / 100m,
|
||||
"Moderate" => costs.ComplexityModeratePercent / 100m,
|
||||
"Complex" => costs.ComplexityComplexPercent / 100m,
|
||||
"Extreme" => costs.ComplexityExtremePercent / 100m,
|
||||
_ => 0m
|
||||
};
|
||||
var complexityCharge = subtotalBeforeComplexity * complexityPct;
|
||||
var subtotal = subtotalBeforeComplexity + complexityCharge;
|
||||
|
||||
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
|
||||
|
||||
// Apply shop minimum
|
||||
if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
|
||||
subtotal = costs.ShopMinimumCharge;
|
||||
|
||||
var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
|
||||
var total = unitPrice * request.Quantity;
|
||||
|
||||
var breakdown = new AiPricingBreakdown
|
||||
{
|
||||
SurfaceAreaSqFt = Math.Round(aiResult.SurfaceAreaSqFt, 2),
|
||||
PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
|
||||
CoatCount = request.CoatCount,
|
||||
MaterialCost = Math.Round(materialCost, 2),
|
||||
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
||||
EstimatedMinutes = (int)Math.Round(perItemMinutes),
|
||||
MaterialMinMinutes = materialMinMinutes,
|
||||
MinFloorApplied = minFloorApplied,
|
||||
LaborCost = Math.Round(laborCost, 2),
|
||||
OvenCycleMinutes = ovenCycleMinutes,
|
||||
OvenCost = 0m,
|
||||
RequiresPreheat = aiResult.RequiresPreheat,
|
||||
PreheatMinutes = preheatMinutes,
|
||||
PreheatCost = Math.Round(preheatCost, 2),
|
||||
SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
|
||||
Complexity = aiResult.Complexity,
|
||||
ComplexityCharge = Math.Round(complexityCharge, 2),
|
||||
SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
|
||||
MarkupAmount = Math.Round(markupAmount, 2),
|
||||
UnitPrice = unitPrice,
|
||||
};
|
||||
|
||||
return (unitPrice, total, breakdown);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user