File size: 15,318 Bytes
191b322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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),
  };
};