| 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> = {}): 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<string> => { |
| 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<AiSuggestion[]> => { |
| 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<CostBreakdown | null> => { |
| 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<ExtractedDPR | null> => { |
| 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<ExtractedBill | null> => { |
| 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<ExtractedDPR | null> => { |
| 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<BOQItem[]> => { |
| 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<any> => { |
| 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), |
| }; |
| }; |
|
|