import { ParsedRFP, CostEstimate, QuoteMatch, CostBreakdown } from './types';
import config from './config';
import { getMatchConfidence } from './matcher';
/**
* Estimate cost and lead time for a parsed RFP
*/
export function estimateCostAndLeadTime(
parsedRfp: ParsedRFP,
matches: QuoteMatch[]
): CostEstimate {
// Step 1: Calculate material cost
const materialCost = calculateMaterialCost(parsedRfp);
// Step 2: Calculate processing cost
const processCost = calculateProcessingCost(parsedRfp);
// Step 3: Calculate labor cost
const laborCost = calculateLaborCost(parsedRfp);
// Step 4: Calculate tooling amortization
const toolingPerUnit = calculateToolingCost(parsedRfp);
// Step 5: Calculate overhead
const directCost = (materialCost + processCost + laborCost) / parsedRfp.qty;
const overhead = directCost * config.overheadPct;
// Step 6: Apply margin
const unitCost = directCost + toolingPerUnit + overhead;
const pricePerUnit = unitCost * (1 + config.marginPct);
const totalPrice = pricePerUnit * parsedRfp.qty;
// Step 7: Estimate lead time
const leadDays = estimateLeadTime(parsedRfp, matches);
// Step 8: Determine confidence
const topScore = matches.length > 0 ? matches[0].score : 0;
let confidence = getMatchConfidence(topScore);
// Adjust confidence based on RFP completeness
if (parsedRfp.confidence === 'low') {
confidence = 'low';
} else if (parsedRfp.confidence === 'medium' && confidence === 'high') {
confidence = 'medium';
}
// Apply contingency for low confidence
const contingencyPct = confidence === 'low' ? config.contingencyPct : 0;
const finalPricePerUnit = pricePerUnit * (1 + contingencyPct);
const finalTotalPrice = finalPricePerUnit * parsedRfp.qty;
// Step 9: Calculate ranges (best/worst case)
const variance = confidence === 'high' ? 0.10 : confidence === 'medium' ? 0.20 : 0.30;
const breakdown: CostBreakdown = {
materialCost,
processCost,
laborCost,
toolingPerUnit,
overhead,
marginPct: config.marginPct,
contingencyPct,
};
return {
pricePerUnit: Number(finalPricePerUnit.toFixed(2)),
totalPrice: Number(finalTotalPrice.toFixed(2)),
leadDays,
confidence,
breakdown,
bestCase: {
price: Number((finalTotalPrice * (1 - variance)).toFixed(2)),
leadDays: Math.max(7, Math.round(leadDays * 0.8)),
},
worstCase: {
price: Number((finalTotalPrice * (1 + variance)).toFixed(2)),
leadDays: Math.round(leadDays * 1.3),
},
};
}
/**
* Calculate material cost
*/
function calculateMaterialCost(rfp: ParsedRFP): number {
const material = rfp.material?.toLowerCase() || 'aluminum';
// Find matching material price
let unitPrice = config.materials['aluminum']; // default
for (const [mat, price] of Object.entries(config.materials)) {
if (material.includes(mat.toLowerCase())) {
unitPrice = price;
break;
}
}
// Estimate material usage (simplified - assumes unit price is per part equivalent)
// In reality, you'd calculate based on part volume, scrap rate, etc.
const materialPerPart = unitPrice;
return materialPerPart * rfp.qty;
}
/**
* Calculate processing cost
*/
function calculateProcessingCost(rfp: ParsedRFP): number {
const processes = rfp.processes || [];
let totalProcessCost = 0;
for (const process of processes) {
const processKey = Object.keys(config.processes).find(
key => key.toLowerCase() === process.toLowerCase()
);
if (processKey) {
const value = config.processes[processKey];
// If value > 10, assume it's minutes per part, convert to $ per part
const costPerPart = value > 10
? (value / 60) * config.machineHourRate
: value;
totalProcessCost += costPerPart * rfp.qty;
}
}
return totalProcessCost;
}
/**
* Calculate labor cost
*/
function calculateLaborCost(rfp: ParsedRFP): number {
// Simplified: assume 0.1 hours per part for setup/inspection
// Plus additional for complex tolerances
let hoursPerPart = 0.1;
if (rfp.tolerances) {
// Tighter tolerances = more inspection time
if (rfp.tolerances.includes('0.001') || rfp.tolerances.includes('0.0001')) {
hoursPerPart += 0.05;
}
}
// Additional labor for finishing operations
const finishingOps = ['anodize', 'powder coat', 'paint', 'polish', 'passivate'];
const hasFinishing = (rfp.processes || []).some(p =>
finishingOps.some(f => p.toLowerCase().includes(f))
);
if (hasFinishing) {
hoursPerPart += 0.05;
}
return hoursPerPart * rfp.qty * config.laborRate;
}
/**
* Calculate tooling cost amortization
*/
function calculateToolingCost(rfp: ParsedRFP): number {
// Tooling cost varies by quantity and complexity
let toolingCost = 0;
if (rfp.qty < 10) {
toolingCost = 1000; // High setup for low volume
} else if (rfp.qty < 50) {
toolingCost = 750;
} else if (rfp.qty < 100) {
toolingCost = 500;
} else if (rfp.qty < 500) {
toolingCost = 300;
} else {
toolingCost = 200;
}
// Additional tooling for complex processes
const complexProcesses = ['weld', 'bend', 'heat treat'];
const hasComplex = (rfp.processes || []).some(p =>
complexProcesses.some(cp => p.toLowerCase().includes(cp))
);
if (hasComplex) {
toolingCost *= 1.5;
}
return toolingCost / Math.max(1, rfp.qty);
}
/**
* Estimate lead time
*/
function estimateLeadTime(rfp: ParsedRFP, matches: QuoteMatch[]): number {
let baseLead = config.defaultLeadDays;
// Adjust based on quantity
if (rfp.qty < 10) {
baseLead = 7;
} else if (rfp.qty < 100) {
baseLead = 14;
} else if (rfp.qty < 500) {
baseLead = 21;
} else {
baseLead = 28;
}
// Add time for complex processes
const processes = rfp.processes || [];
if (processes.some(p => /heat.treat|plat/i.test(p))) {
baseLead += 7;
}
if (processes.some(p => /anodize|powder.coat/i.test(p))) {
baseLead += 3;
}
// Adjust based on historical matches
if (matches.length > 0 && matches[0].score > 0.7) {
const historicalLead = matches[0].quote.actualLeadDays || matches[0].quote.leadDays;
// Blend historical and calculated
baseLead = Math.round((baseLead + historicalLead * matches[0].score) / (1 + matches[0].score));
}
// Check requested due date
if (rfp.dueDate) {
const dueDate = new Date(rfp.dueDate);
const now = new Date();
const requestedDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (requestedDays > 0 && requestedDays < baseLead) {
// Customer needs it faster - flag this
baseLead = Math.max(baseLead, requestedDays + 7); // Add buffer
}
}
return baseLead;
}
/**
* Format estimate for display
*/
export function formatEstimateForReview(estimate: CostEstimate): string {
let output = `Cost Estimate (Confidence: ${estimate.confidence.toUpperCase()})\n`;
output += '='.repeat(60) + '\n\n';
output += `Price per Unit: $${estimate.pricePerUnit.toFixed(2)}\n`;
output += `Total Price: $${estimate.totalPrice.toFixed(2)}\n`;
output += `Lead Time: ${estimate.leadDays} days\n\n`;
if (estimate.bestCase && estimate.worstCase) {
output += `Range:\n`;
output += ` Best case: $${estimate.bestCase.price.toFixed(2)} in ${estimate.bestCase.leadDays} days\n`;
output += ` Worst case: $${estimate.worstCase.price.toFixed(2)} in ${estimate.worstCase.leadDays} days\n\n`;
}
output += 'Cost Breakdown:\n';
const b = estimate.breakdown;
output += ` Material: $${b.materialCost.toFixed(2)}\n`;
output += ` Processing: $${b.processCost.toFixed(2)}\n`;
output += ` Labor: $${b.laborCost.toFixed(2)}\n`;
output += ` Tooling/unit: $${b.toolingPerUnit.toFixed(2)}\n`;
output += ` Overhead: $${b.overhead.toFixed(2)}\n`;
output += ` Margin: ${(b.marginPct * 100).toFixed(0)}%\n`;
if (b.contingencyPct && b.contingencyPct > 0) {
output += ` Contingency: ${(b.contingencyPct * 100).toFixed(0)}%\n`;
}
return output;
}