ConstructionProcectmanagement / services /localAnalysisService.ts
Codex Deploy
Prepare local Hugging Face deployment
191b322
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),
};
};