NewTenderData / app.py
Boka73's picture
Update app.py
9d645be verified
Raw
History Blame Contribute Delete
21.9 kB
#!/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()