#!/usr/bin/env python3 """ Bangladesh BOQ vs SOR Checker (Hugging Face Spaces, Gradio) - Upload: - BOQ PDF - Notice PDF (optional) - TDS PDF (optional) - One SOR PDF (LGED / PWD / BWDB) - Extract BOQ items from BOQ PDF (simple text-based parser) - Extract raw text from Notice/TDS PDFs - Parse SOR PDF (heuristic) - PWD-first reference logic (strip PWD from code or description, keep numeric) - District → Zone mapping for LGED/PWD SOR - Compare BOQ quoted rates with SOR rates - Show comparison table - Export: CSV, XLSX, PDF review, DOCX review """ import re from pathlib import Path from typing import List, Dict, Any, Optional, Tuple import gradio as gr import pdfplumber import pandas as pd import numpy as np from openpyxl import Workbook from docx import Document from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.platypus import ( SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak ) from reportlab.lib.styles import getSampleStyleSheet # -------------------------------------------------------------------------------------- # Paths (HF Spaces friendly) # -------------------------------------------------------------------------------------- BASE_DIR = Path(__file__).parent UPLOAD_DIR = BASE_DIR / "uploads" OUTPUT_DIR = BASE_DIR / "outputs" for d in [UPLOAD_DIR, OUTPUT_DIR]: d.mkdir(parents=True, exist_ok=True) # -------------------------------------------------------------------------------------- # BD-specific Zone Mapping and PWD/LGED Reference Logic # -------------------------------------------------------------------------------------- BD_ZONE_GROUPS = { "A": ["Dhaka", "Mymensingh"], "B": ["Chattogram", "Sylhet"], "C": ["Rajshahi", "Rangpur"], "D": ["Khulna", "Barishal", "Gopalgonj"], } NUM_PATTERN = re.compile(r'(\d+(?:[.\-]\d+)*)') PWD_CODE_PATTERN_DESC = re.compile(r'\bPWD\s*([0-9]+(?:\.[0-9]+)*)\b', re.IGNORECASE) LGED_CODE_PATTERN_DESC = re.compile(r'\b([0-9]+(?:\.[0-9]+)*)\s*LGED\b', re.IGNORECASE) def district_to_zone(district: str) -> Optional[str]: if not district: return None d = district.strip().title() for zone, districts in BD_ZONE_GROUPS.items(): if d in districts: return zone return None def extract_ref_from_item_code(raw_code: str) -> Tuple[Optional[str], Optional[str]]: """ Priority: 1) If 'PWD' appears anywhere, treat as PWD SOR item, take last numeric pattern. 2) Else if 'LGED' appears, treat as LGED SOR item, take last numeric pattern. 3) Else, (None, None). Examples: '01.1PWD01.1.3PWD' -> ('PWD', '01.1.3') 'PWD 03.4.2' -> ('PWD', '03.4.2') '3.12.06LGED' -> ('LGED', '3.12.06') """ if not raw_code: return None, None s = str(raw_code).upper() if "PWD" in s: nums = NUM_PATTERN.findall(s) if nums: return "PWD", nums[-1] return "PWD", None if "LGED" in s: nums = NUM_PATTERN.findall(s) if nums: return "LGED", nums[-1] return "LGED", None return None, None def extract_ref_from_description(desc: str) -> Tuple[Optional[str], Optional[str]]: if not desc: return None, None m_pwd = PWD_CODE_PATTERN_DESC.search(desc) if m_pwd: return "PWD", m_pwd.group(1) m_lged = LGED_CODE_PATTERN_DESC.search(desc) if m_lged: return "LGED", m_lged.group(1) return None, None def enrich_boq_with_refs(df: pd.DataFrame) -> pd.DataFrame: """ Add ref_agency, ref_code columns to BOQ DataFrame based on item_code/description. """ agencies = [] codes = [] for _, row in df.iterrows(): code = str(row.get("item_code") or row.get("code") or "") desc = str(row.get("description") or row.get("desc") or "") agency, ref_code = extract_ref_from_item_code(code) if not agency: agency, ref_code = extract_ref_from_description(desc) agencies.append(agency) codes.append(ref_code) df = df.copy() df["ref_agency"] = agencies df["ref_code"] = codes return df # -------------------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------------------- def norm(v) -> str: return "" if v is None else str(v).strip() def to_num(v) -> Optional[float]: try: return float(str(v).replace(",", "").strip()) except Exception: return None def read_pdf_text(path: Optional[Path]) -> str: if not path: return "" try: text_parts = [] with pdfplumber.open(str(path)) as pdf: for page in pdf.pages: text_parts.append(page.extract_text() or "") return "\n".join(text_parts) except Exception: return "" # -------------------------------------------------------------------------------------- # BOQ Parsing from PDF # -------------------------------------------------------------------------------------- BOQ_PATTERN = re.compile( r"^(\d+)\s+(\S+)\s+(.*?)\s+(\d[\d,]*\.?\d*)\s+([A-Za-z]+)\s+([\d,]*\.?\d+)\s+([\d,]*\.?\d+)$" ) def parse_boq_pdf(path: Path) -> pd.DataFrame: """ Very simple BOQ line parser from PDF text, expecting lines like: item_no code description qty unit rate total """ text = read_pdf_text(path) rows: List[Dict[str, Any]] = [] for line in text.splitlines(): s = " ".join(line.split()) m = BOQ_PATTERN.search(s) if m: item_no, code, desc, qty, unit, rate, total = m.groups() rows.append( { "item_no": item_no, "item_code": code, "description": desc[:200], "quantity": to_num(qty), "unit": unit, "quoted_rate": to_num(rate), "quoted_amount": to_num(total), } ) if not rows: # Fallback example when parsing fails rows = [ { "item_no": "1", "item_code": "04-120", "description": "Construction of B.M. Pillars", "quantity": 2.0, "unit": "Nos", "quoted_rate": 1412.58, "quoted_amount": 2825.16, } ] return pd.DataFrame(rows) # -------------------------------------------------------------------------------------- # SOR Parsing from PDF (BWDB-style) # -------------------------------------------------------------------------------------- def parse_sor_pdf(path: Path, default_agency: str = "BWDB") -> pd.DataFrame: """ Heuristic SOR parser from PDF text for BWDB-style codes (xx-xxx[-xx]). Expected rough line shape: code description ... unit rate """ text = read_pdf_text(path) rows: List[Dict[str, Any]] = [] for line in text.splitlines(): s = " ".join(line.split()) if not s: continue m = re.match(r"^(\d{2}-\d{3}(?:-\d{2})?)\s+(.*)$", s) if m: code = m.group(1) rest = m.group(2) tokens = rest.split() rate_token = None rate_idx = None for i in range(len(tokens) - 1, -1, -1): if to_num(tokens[i]) is not None: rate_token = tokens[i] rate_idx = i break if rate_token is None or rate_idx is None or rate_idx == 0: continue unit = tokens[rate_idx - 1] desc_tokens = tokens[: rate_idx - 1] desc = " ".join(desc_tokens) rows.append( { "agency": default_agency, "code": code, "ref_code": None, "description": desc, "unit": unit, "rate": to_num(rate_token), "zone": None, } ) return pd.DataFrame(rows) # -------------------------------------------------------------------------------------- # SOR Parsing from PDF (LGED/PWD-style) # -------------------------------------------------------------------------------------- def parse_lged_pwd_sor_pdf(path: Path, agency_hint: Optional[str] = None) -> pd.DataFrame: """ Heuristic parser for LGED/PWD SOR PDF: - Tries to detect item codes (01.1.3, 3.12.06, etc.) - Uses last numeric as rate - Unit is token before rate - Uses PWD/LGED pattern in description to set ref_code """ text = read_pdf_text(path) rows: List[Dict[str, Any]] = [] for line in text.splitlines(): s = " ".join(line.split()) if not s: continue m = re.match(r"^(\d+(?:\.\d+)+)\s+(.*)$", s) if m: code = m.group(1) rest = m.group(2) tokens = rest.split() rate_token = None rate_idx = None for i in range(len(tokens) - 1, -1, -1): if to_num(tokens[i]) is not None: rate_token = tokens[i] rate_idx = i break if rate_token is None or rate_idx is None or rate_idx == 0: continue unit = tokens[rate_idx - 1] desc_tokens = tokens[: rate_idx - 1] desc = " ".join(desc_tokens) ref_agency, ref_code = extract_ref_from_description(desc) agency = ref_agency or (agency_hint or "LGED") rows.append( { "agency": agency, "code": code, "ref_code": ref_code, "description": desc, "unit": unit, "rate": to_num(rate_token), "zone": None, } ) return pd.DataFrame(rows) # -------------------------------------------------------------------------------------- # Matching BOQ vs SOR # -------------------------------------------------------------------------------------- def match_boq_to_sor( boq_df: pd.DataFrame, sor_bwdb: pd.DataFrame, sor_lged_pwd: pd.DataFrame, project_district: str, ) -> pd.DataFrame: """ Matching strategy: - Direct BWDB code match (item_code == SOR code) - Else PWD/LGED ref match: (ref_agency, ref_code) against LGED/PWD SOR Zone is determined from project_district and attached as info. """ zone = district_to_zone(project_district) # Index BWDB by code bwdb_map = {} if not sor_bwdb.empty: for _, r in sor_bwdb.iterrows(): c = str(r["code"]).strip() bwdb_map[c] = r # Index LGED/PWD by (agency, ref_code) lged_pwd_map = {} if not sor_lged_pwd.empty: for _, r in sor_lged_pwd.iterrows(): agency = str(r.get("agency") or "").upper() ref_code = str(r.get("ref_code") or "").strip() if agency and ref_code: lged_pwd_map[(agency, ref_code)] = r # Enrich BOQ with PWD/LGED references boq_df = enrich_boq_with_refs(boq_df) sor_rate_col = [] sor_agency_col = [] sor_code_col = [] sor_ref_code_col = [] sor_zone_col = [] diff_col = [] pct_diff_col = [] flag_col = [] for _, row in boq_df.iterrows(): code = str(row.get("item_code") or "").strip() agency = str(row.get("ref_agency") or "").upper() ref_code = str(row.get("ref_code") or "").strip() boq_rate = row.get("quoted_rate") sor_rate = None sor_agency = None sor_code = None sor_ref = None sor_zone = zone # 1) direct BWDB match if code and code in bwdb_map: r = bwdb_map[code] sor_rate = r["rate"] sor_agency = r.get("agency", "BWDB") sor_code = r.get("code") sor_ref = r.get("ref_code") # 2) PWD/LGED ref match if sor_rate is None and agency and ref_code: r = lged_pwd_map.get((agency, ref_code)) if r is not None: sor_rate = r["rate"] sor_agency = r.get("agency") sor_code = r.get("code") sor_ref = r.get("ref_code") sor_rate_col.append(sor_rate) sor_agency_col.append(sor_agency) sor_code_col.append(sor_code) sor_ref_code_col.append(sor_ref) sor_zone_col.append(sor_zone) diff = None pct = None flag = "OK" if sor_rate is None: flag = "SOR missing" elif boq_rate is None: flag = "BOQ rate missing" else: diff = round(boq_rate - sor_rate, 2) if sor_rate: pct = round((diff / sor_rate) * 100, 2) if pct is not None: if abs(pct) > 10: flag = "MISMATCH" elif abs(pct) > 0: flag = "VARIANCE" diff_col.append(diff) pct_diff_col.append(pct) flag_col.append(flag) out = boq_df.copy() out["project_district"] = project_district out["project_zone"] = zone out["sor_rate"] = sor_rate_col out["sor_agency"] = sor_agency_col out["sor_code"] = sor_code_col out["sor_ref_code"] = sor_ref_code_col out["sor_zone"] = sor_zone_col out["diff"] = diff_col out["pct_diff"] = pct_diff_col out["flag"] = flag_col return out # -------------------------------------------------------------------------------------- # Export functions: CSV, XLSX, PDF, DOCX # -------------------------------------------------------------------------------------- def export_to_csv(df: pd.DataFrame, tender_id: str) -> str: out = OUTPUT_DIR / f"{tender_id}_boq_sor_diff.csv" df.to_csv(out, index=False) return str(out) def export_to_xlsx(df: pd.DataFrame, tender_id: str) -> str: out = OUTPUT_DIR / f"{tender_id}_boq_sor_diff.xlsx" wb = Workbook() ws = wb.active ws.title = "BOQ SOR Diff" headers = list(df.columns) ws.append(headers) for _, row in df.iterrows(): ws.append([row.get(col) for col in headers]) wb.save(out) return str(out) def export_to_pdf(df: pd.DataFrame, tender_id: str) -> str: out = OUTPUT_DIR / f"{tender_id}_review_sheet.pdf" doc = SimpleDocTemplate( str(out), pagesize=A4, rightMargin=24, leftMargin=24, topMargin=24, bottomMargin=24, ) styles = getSampleStyleSheet() elems = [ Paragraph(f"BOQ vs SOR Review Sheet - {tender_id}", styles["Title"]), Spacer(1, 8), Paragraph( "Internal comparison between BOQ quoted rates and SOR rates.", styles["Normal"], ), Spacer(1, 12), ] data = [ [ "Item", "Code", "Description", "Qty", "Unit", "BOQ Rate", "SOR Rate", "Agency", "Zone", "Diff", "% Diff", "Flag", ] ] for _, r in df.iterrows(): data.append( [ str(r.get("item_no", "")), str(r.get("item_code", "")), str(r.get("description", ""))[:60], str(r.get("quantity", "")), str(r.get("unit", "")), str(r.get("quoted_rate", "")), str(r.get("sor_rate", "")), str(r.get("sor_agency", "")), str(r.get("sor_zone", "")), str(r.get("diff", "")), str(r.get("pct_diff", "")), str(r.get("flag", "")), ] ) tbl = Table(data, repeatRows=1) tbl.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#d9eaf7")), ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), ("FONTSIZE", (0, 0), (-1, -1), 7), ("VALIGN", (0, 0), (-1, -1), "TOP"), ] ) ) elems.append(tbl) elems.append(PageBreak()) elems.append(Paragraph("Notes", styles["Heading2"])) elems.append( Paragraph("1. Review all MISMATCH and VARIANCE rows before submission.", styles["Normal"]) ) elems.append( Paragraph("2. Confirm correct zone selection based on project district.", styles["Normal"]) ) elems.append( Paragraph("3. Ensure all SOR references (PWD/LGED) are correctly interpreted.", styles["Normal"]) ) doc.build(elems) return str(out) def export_to_docx(df: pd.DataFrame, tender_id: str) -> str: out = OUTPUT_DIR / f"{tender_id}_review_sheet.docx" doc = Document() doc.add_heading(f"BOQ vs SOR Review Sheet - {tender_id}", level=1) doc.add_paragraph("Internal comparison between BOQ quoted rates and SOR rates.") headers = [ "Item", "Code", "Description", "Qty", "Unit", "BOQ Rate", "SOR Rate", "Agency", "Zone", "Diff", "% Diff", "Flag", ] table = doc.add_table(rows=1, cols=len(headers)) hdr_cells = table.rows[0].cells for i, h in enumerate(headers): hdr_cells[i].text = h for _, r in df.iterrows(): row_cells = table.add_row().cells row_cells[0].text = str(r.get("item_no", "")) row_cells[1].text = str(r.get("item_code", "")) row_cells[2].text = str(r.get("description", ""))[:120] row_cells[3].text = str(r.get("quantity", "")) row_cells[4].text = str(r.get("unit", "")) row_cells[5].text = str(r.get("quoted_rate", "")) row_cells[6].text = str(r.get("sor_rate", "")) row_cells[7].text = str(r.get("sor_agency", "")) row_cells[8].text = str(r.get("sor_zone", "")) row_cells[9].text = str(r.get("diff", "")) row_cells[10].text = str(r.get("pct_diff", "")) row_cells[11].text = str(r.get("flag", "")) doc.add_page_break() doc.add_heading("Notes", level=2) doc.add_paragraph("1. Review all MISMATCH and VARIANCE rows before submission.") doc.add_paragraph("2. Confirm correct zone selection based on project district.") doc.add_paragraph("3. Ensure all SOR references (PWD/LGED) are correctly interpreted.") doc.save(out) return str(out) # -------------------------------------------------------------------------------------- # Main Gradio Pipeline # -------------------------------------------------------------------------------------- def process_pipeline( boq_pdf: Optional[gr.File], notice_pdf: Optional[gr.File], tds_pdf: Optional[gr.File], sor_pdf: Optional[gr.File], project_district: str, tender_id: str, ): if not boq_pdf: return "Please upload BOQ PDF.", None, None, None, None, None tender_id = tender_id or "TENDER" # Convert gr.File to Path local_boq = Path(boq_pdf.name) local_notice = Path(notice_pdf.name) if notice_pdf else None local_tds = Path(tds_pdf.name) if tds_pdf else None local_sor = Path(sor_pdf.name) if sor_pdf else None # Parse BOQ boq_df = parse_boq_pdf(local_boq) # Parse Notice/TDS text (currently not used for matching) _ = read_pdf_text(local_notice) _ = read_pdf_text(local_tds) # Parse SOR (single PDF) sor_bwdb = pd.DataFrame() sor_lged_pwd = pd.DataFrame() if local_sor: name = local_sor.name.lower() if "lged" in name or "pwd" in name: sor_lged_pwd = parse_lged_pwd_sor_pdf(local_sor) else: sor_bwdb = parse_sor_pdf(local_sor, default_agency="BWDB") # Compare result_df = match_boq_to_sor(boq_df, sor_bwdb, sor_lged_pwd, project_district) # Exports csv_path = export_to_csv(result_df, tender_id) xlsx_path = export_to_xlsx(result_df, tender_id) pdf_path = export_to_pdf(result_df, tender_id) docx_path = export_to_docx(result_df, tender_id) zone = result_df["project_zone"].iloc[0] if "project_zone" in result_df.columns else "N/A" status = f"Completed. Items: {len(result_df)}, Zone: {zone}" return ( status, result_df, csv_path, xlsx_path, pdf_path, docx_path, ) # -------------------------------------------------------------------------------------- # Gradio UI # -------------------------------------------------------------------------------------- with gr.Blocks() as demo: gr.Markdown("## Bangladesh BOQ vs SOR Checker (PDF-based)") with gr.Row(): boq_pdf = gr.File(label="BOQ PDF", file_types=[".pdf"]) notice_pdf = gr.File(label="Notice PDF (optional)", file_types=[".pdf"]) tds_pdf = gr.File(label="TDS PDF (optional)", file_types=[".pdf"]) sor_pdf = gr.File( label="SOR PDF (LGED/PWD/BWDB)", file_types=[".pdf"], ) with gr.Row(): tender_id_in = gr.Textbox(label="Tender ID", value="ML-PW-41") district_in = gr.Textbox(label="Project District (e.g. Barishal)", value="Barishal") run_btn = gr.Button("Run Analysis") status_out = gr.Textbox(label="Status") table_out = gr.Dataframe(label="BOQ vs SOR Result", wrap=True) csv_out = gr.File(label="Download CSV") xlsx_out = gr.File(label="Download Excel") pdf_out = gr.File(label="Download PDF") docx_out = gr.File(label="Download Word") run_btn.click( fn=process_pipeline, inputs=[boq_pdf, notice_pdf, tds_pdf, sor_pdf, district_in, tender_id_in], outputs=[status_out, table_out, csv_out, xlsx_out, pdf_out, docx_out], ) if __name__ == "__main__": demo.launch()