import { ProjectState, ExtractedDPR, ExtractedBill, BOQItem, AiSuggestion, CostBreakdown, DocumentCategory, ModuleType, Unit, } from "../types"; const today = () => new Date().toISOString().split("T")[0]; const money = (value: number) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value || 0); const round = (value: number, digits = 2) => { const factor = 10 ** digits; return Math.round((Number.isFinite(value) ? value : 0) * factor) / factor; }; const containsAny = (text: string, words: string[]) => words.some((word) => text.includes(word)); const getText = (value = "") => value.toLowerCase(); const buildBreakdown = (total: number, profile: Partial = {}): CostBreakdown => { const material = round(profile.material ?? total * 0.58); const labor = round(profile.labor ?? total * 0.28); const equipment = round(profile.equipment ?? total * 0.08); const overhead = round(total - material - labor - equipment); return { material, labor, equipment, overhead }; }; const findAmount = (text: string): number | undefined => { const match = text.match(/(?:bdt|tk|taka|rs|inr|\$)?\s*([\d,]+(?:\.\d+)?)/i); if (!match) return undefined; const amount = Number(match[1].replace(/,/g, "")); return Number.isFinite(amount) ? amount : undefined; }; const findDate = (text: string): string | undefined => { const iso = text.match(/\b(20\d{2})[-/](0?[1-9]|1[0-2])[-/](0?[1-9]|[12]\d|3[01])\b/); if (iso) { const [, year, month, day] = iso; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } const dmy = text.match(/\b(0?[1-9]|[12]\d|3[01])[-/](0?[1-9]|1[0-2])[-/](20\d{2})\b/); if (dmy) { const [, day, month, year] = dmy; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } return undefined; }; const matchBOQ = (text: string, boqItems: BOQItem[]) => { const normalized = getText(text); return boqItems.find((item) => { const words = getText(item.description) .split(/[^a-z0-9]+/) .filter((word) => word.length > 3); return words.length > 0 && words.some((word) => normalized.includes(word)); }); }; const getProgress = (projectData: ProjectState) => { const totalPlannedValue = projectData.boq.reduce((sum, item) => sum + item.plannedQty * item.rate, 0); const totalExecutedValue = projectData.boq.reduce((sum, item) => sum + item.executedQty * item.rate, 0); const percent = totalPlannedValue > 0 ? (totalExecutedValue / totalPlannedValue) * 100 : 0; return { totalPlannedValue, totalExecutedValue, percent }; }; const makeSuggestion = ( type: AiSuggestion["type"], title: string, description: string, value?: any, linkedId?: string, docId = "LOCAL" ): AiSuggestion => ({ id: `SUG-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, docId, type, title, description, value, linkedId, status: "PENDING", }); export const generateProjectInsights = async (projectData: ProjectState): Promise => { const { totalPlannedValue, totalExecutedValue, percent } = getProgress(projectData); const pendingHigh = projectData.boq .filter((item) => item.priority === "HIGH" && item.executedQty < item.plannedQty) .sort((a, b) => (b.plannedQty - b.executedQty) * b.rate - (a.plannedQty - a.executedQty) * a.rate) .slice(0, 3); const atRiskMilestones = projectData.milestones.filter((m) => m.status === "AT_RISK" || m.status === "PENDING").slice(0, 3); const liabilities = projectData.liabilities.reduce((sum, item) => sum + item.amount, 0); const healthScore = Math.max(0, Math.min(100, Math.round(percent - pendingHigh.length * 5 - (liabilities > totalExecutedValue * 0.2 ? 8 : 0)))); const pendingText = pendingHigh.length ? pendingHigh .map((item, index) => `${index + 1}. ${item.description}: ${round(item.plannedQty - item.executedQty)} ${item.unit} pending.`) .join("\n") : "No high-priority pending BOQ items are currently blocking progress."; const milestoneText = atRiskMilestones.length ? atRiskMilestones.map((m) => `- ${m.title} due ${m.date} (${m.status}).`).join("\n") : "- No pending milestone risk is currently flagged."; return `## Project Health Score: **${healthScore}%**. Current executed value is ${round(percent, 1)}% of planned value (${money(totalExecutedValue)} of ${money(totalPlannedValue)}). ## Critical Pending Actions ${pendingText} ## Milestone Risk ${milestoneText} ## Recommendation Focus the next review on high-priority BOQ quantities, material availability, and pending liabilities before approving new commitments.`; }; export const analyzeDocumentContent = async ( docName: string, category: string, boqItems: BOQItem[], fileContent?: string, mimeType: string = "application/pdf" ): Promise => { const suggestions: AiSuggestion[] = []; const name = getText(docName); const matched = matchBOQ(docName, boqItems); if (category === "REPORT") { suggestions.push( makeSuggestion( "DPR_ENTRY", "Local DPR entry candidate", `Created from document name "${docName}". Review quantities before applying.`, await extractDPRData(docName, boqItems, fileContent, mimeType), matched?.id ) ); } if (category === "BILL") { suggestions.push( makeSuggestion( "BILL_DETECTION", "Local bill candidate", `Detected a bill or invoice from "${docName}". Review amount and entity before applying.`, await extractBillData(docName, fileContent, mimeType), matched?.id ) ); } if (category === "CONTRACT" || containsAny(name, ["boq", "schedule", "quantity", "estimate"])) { suggestions.push(makeSuggestion("BOQ_IMPORT", "BOQ import available", "Use the local parser to create draft BOQ rows from this document name.")); } if (containsAny(name, ["risk", "delay", "unsafe", "accident", "rain", "shortage"])) { suggestions.push( makeSuggestion( "RISK_ALERT", "Potential risk flagged", "The file name contains a risk keyword. Review the document and add mitigation notes if needed." ) ); } return suggestions; }; export const suggestPlannedUnitCost = async ( description: string, unit: string, existingBOQ: BOQItem[] ): Promise<{ total: number; breakdown: CostBreakdown } | null> => { const lower = getText(description); const sameUnit = existingBOQ.filter((item) => item.unit === unit); const comparable = sameUnit.length ? sameUnit : existingBOQ; const average = comparable.length ? comparable.reduce((sum, item) => sum + (item.plannedUnitCost || item.rate * 0.85), 0) / comparable.length : 1000; let multiplier = 1; if (containsAny(lower, ["concrete", "rcc", "cement"])) multiplier = 1.15; if (containsAny(lower, ["earth", "sand", "filling", "excavation"])) multiplier = 0.75; if (containsAny(lower, ["steel", "rebar", "rod"])) multiplier = 1.35; if (containsAny(lower, ["formwork", "shuttering"])) multiplier = 0.95; const total = round(Math.max(1, average * multiplier)); return { total, breakdown: buildBreakdown(total) }; }; export const suggestActualCostBreakdown = async ( description: string, actualUnitCost: number, plannedBreakdown?: CostBreakdown ): Promise => { if (!actualUnitCost || actualUnitCost <= 0) return null; if (plannedBreakdown) { const plannedTotal = plannedBreakdown.material + plannedBreakdown.labor + plannedBreakdown.equipment + plannedBreakdown.overhead; if (plannedTotal > 0) { return buildBreakdown(actualUnitCost, { material: (plannedBreakdown.material / plannedTotal) * actualUnitCost, labor: (plannedBreakdown.labor / plannedTotal) * actualUnitCost, equipment: (plannedBreakdown.equipment / plannedTotal) * actualUnitCost, }); } } const lower = getText(description); if (containsAny(lower, ["earth", "excavation", "transport"])) { return buildBreakdown(actualUnitCost, { material: actualUnitCost * 0.25, labor: actualUnitCost * 0.25, equipment: actualUnitCost * 0.4 }); } return buildBreakdown(actualUnitCost); }; export const suggestDocumentMetadata = async (fileName: string, userRole: string): Promise<{ category: DocumentCategory; module: ModuleType } | null> => { const name = getText(fileName); if (containsAny(name, ["bill", "invoice", "ra", "payment"])) return { category: "BILL", module: "FINANCE" }; if (containsAny(name, ["dpr", "daily", "progress", "report", "site"])) return { category: "REPORT", module: "SITE" }; if (containsAny(name, ["drawing", "layout", "plan", "dwg", "design"])) return { category: "DRAWING", module: "MASTER" }; if (containsAny(name, ["permit", "approval", "license", "noc"])) return { category: "PERMIT", module: "GENERAL" }; if (containsAny(name, ["contract", "agreement", "boq", "quantity", "estimate"])) return { category: "CONTRACT", module: "MASTER" }; return { category: "OTHER", module: userRole === "ACCOUNTANT" ? "FINANCE" : "GENERAL" }; }; export const extractDPRData = async ( docName: string, boqItems: BOQItem[], fileContent?: string, mimeType: string = "application/pdf" ): Promise => { const text = `${docName} ${mimeType === "text/plain" ? fileContent || "" : ""}`; const matched = matchBOQ(text, boqItems); const quantityMatch = text.match(/(\d+(?:\.\d+)?)\s*(cum|sqm|nos|kg|rmt|cft|bag|ton)?/i); const laborMatch = text.match(/(?:labor|labour|worker|workers|men)\D*(\d+)/i); return { date: findDate(text) || today(), activity: matched?.description || docName.replace(/\.[^.]+$/, ""), location: text.match(/(?:at|location|section)\s+([a-z0-9 -]+)/i)?.[1]?.trim() || "Site", laborCount: laborMatch ? Number(laborMatch[1]) : 0, remarks: "Parsed locally from available text. Review before applying.", workDoneQty: quantityMatch ? Number(quantityMatch[1]) : undefined, linkedBoqId: matched?.id, materials: [], }; }; export const extractBillData = async ( docName: string, fileContent?: string, mimeType: string = "application/pdf" ): Promise => { const text = `${docName} ${mimeType === "text/plain" ? fileContent || "" : ""}`; const cleanName = docName.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " "); const type = containsAny(getText(text), ["client", "ra", "running account"]) ? "CLIENT_RA" : "VENDOR_INVOICE"; return { entityName: cleanName.split(/\b(?:bill|invoice|ra)\b/i)[0].trim() || "Unknown Entity", amount: findAmount(text), date: findDate(text) || today(), type, }; }; export const parseRunningBillDetails = async (docName: string, boqItems: BOQItem[]): Promise<{ boqId: string; amount: number }[]> => { const amount = findAmount(docName); const matches = boqItems.filter((item) => matchBOQ(`${docName} ${item.description}`, [item])); const selected = matches.length ? matches : boqItems.slice(0, 3); if (!selected.length) return []; const total = amount || selected.reduce((sum, item) => sum + Math.max(0, item.executedQty * item.rate - (item.billedAmount || 0)), 0); const share = total / selected.length; return selected.map((item) => ({ boqId: item.id, amount: round(share) })).filter((item) => item.amount > 0); }; export const processWhatsAppMessage = async ( message: string, boqItems: BOQItem[] ): Promise => { if (!message.trim()) return null; return extractDPRData(message, boqItems, message, "text/plain"); }; export const parseBOQDocument = async ( docName: string, fileContent?: string, mimeType: string = "application/pdf" ): Promise => { const text = mimeType === "text/plain" && fileContent ? fileContent : docName; const lines = text.split(/\r?\n|;/).map((line) => line.trim()).filter(Boolean); const sourceLines = lines.length > 1 ? lines : [docName.replace(/\.[^.]+$/, "")]; return sourceLines.slice(0, 12).map((line, index) => { const qty = Number(line.match(/(?:qty|quantity)?\s*(\d+(?:\.\d+)?)/i)?.[1]) || 1; const rate = findAmount(line) || 1000; const unitMatch = line.match(/\b(SQM|CUM|KG|NOS|RMT|CFT|BAG|TON)\b/i)?.[1]?.toUpperCase() as Unit | undefined; const plannedCost = round(rate * 0.85); return { id: `LOCAL-${Date.now()}-${index + 1}`, description: line || `Imported BOQ Item ${index + 1}`, unit: unitMatch || Unit.NOS, rate, plannedQty: qty, executedQty: 0, plannedUnitCost: plannedCost, plannedBreakdown: buildBreakdown(plannedCost), priority: "MEDIUM", } as BOQItem; }); }; export const generatePredictiveRiskAssessment = async (projectData: ProjectState): Promise => { const { percent } = getProgress(projectData); const pendingHigh = projectData.boq.filter((item) => item.priority === "HIGH" && item.executedQty < item.plannedQty); const lowStock = projectData.materials.filter((m) => m.totalReceived > 0 && m.currentStock < m.totalReceived * 0.1); const overdueMilestones = projectData.milestones.filter((m) => m.status !== "COMPLETED" && new Date(m.date).getTime() < Date.now()); const openLiabilities = projectData.liabilities.reduce((sum, item) => sum + item.amount, 0); const risks = [ ...pendingHigh.slice(0, 2).map((item) => ({ category: "SCHEDULE", impact: "HIGH" as const, probability: 0.72, description: `${item.description} still has ${round(item.plannedQty - item.executedQty)} ${item.unit} pending.`, mitigation: "Assign a daily production target and verify material/labor availability before the next site review.", })), ...lowStock.slice(0, 2).map((item) => ({ category: "SUPPLY_CHAIN", impact: "MEDIUM" as const, probability: 0.62, description: `${item.name} stock is below 10% of received quantity.`, mitigation: "Raise procurement alert and confirm supplier lead time.", })), ...overdueMilestones.slice(0, 2).map((item) => ({ category: "SCHEDULE", impact: "HIGH" as const, probability: 0.8, description: `${item.title} is past its planned date (${item.date}).`, mitigation: "Update the recovery schedule and identify dependencies blocking completion.", })), ]; if (openLiabilities > 0) { risks.push({ category: "FINANCIAL", impact: openLiabilities > projectData.contractValue * 0.1 ? "HIGH" : "MEDIUM", probability: 0.58, description: `Open liabilities total ${money(openLiabilities)}.`, mitigation: "Reconcile liabilities against bills and prioritize approvals by due date.", }); } const fallbackRisks = risks.length ? risks : [ { category: "GENERAL", impact: "LOW" as const, probability: 0.25, description: "No major local risk signal found in the current project data.", mitigation: "Continue weekly review of BOQ progress, materials, and milestone dates.", }, ]; const overallRiskScore = Math.min(100, Math.max(10, Math.round((100 - percent) * 0.35 + pendingHigh.length * 12 + lowStock.length * 8 + overdueMilestones.length * 15))); return { lastUpdated: new Date().toISOString(), overallRiskScore, risks: fallbackRisks.slice(0, 5), }; };