Boka73's picture
Deploy Gradio app
dd6303a verified
"""
Report Generator
----------------
Generates the full output package for a tender:
- Rate Check Excel (color-coded)
- Summary PDF / text report
- Per-document status list
"""
import pathlib
from typing import List
from ..checker.rate_checker import CheckSummary, STATUS_COLORS
def generate_rate_check_excel(summary: CheckSummary, output_path: str) -> str:
"""Generate color-coded Excel rate check report."""
try:
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
except ImportError:
raise ImportError("openpyxl is required. Run: pip install openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Rate Check"
thin = Side(style="thin")
bdr = Border(left=thin, right=thin, top=thin, bottom=thin)
bold = Font(bold=True, size=11)
ctr = Alignment(horizontal="center", vertical="center", wrap_text=True)
left = Alignment(horizontal="left", vertical="center", wrap_text=True)
def fill(hex_color):
return PatternFill("solid", fgColor=hex_color.replace("#", ""))
# ── Title row ───────────────────────────────────────────────────
ws.merge_cells("A1:L1")
c = ws["A1"]
c.value = f"BOQ Rate Check Report β€” Tender ID: {summary.tender_id} | Zone: {summary.zone}"
c.font = Font(bold=True, size=13)
c.alignment = ctr
c.fill = fill("#1F4E79")
c.font = Font(bold=True, size=13, color="FFFFFF")
ws.row_dimensions[1].height = 24
# ── Summary block ────────────────────────────────────────────────
summary_data = [
("Total Items", summary.total_items),
("MATCH", summary.match_count),
("MISMATCH", summary.mismatch_count),
("MISSING", summary.missing_count),
("ABOVE SOR", summary.above_sor_count),
("BELOW SOR", summary.below_sor_count),
("Risk Level", summary.risk_level),
("Total BOQ", f"Tk. {summary.total_boq_amount:,.0f}"),
("Total Quoted",f"Tk. {summary.total_quoted_amount:,.0f}"),
("Total SOR", f"Tk. {summary.total_sor_amount:,.0f}"),
("Overall Diff",f"{summary.overall_diff_pct:+.2f}%"),
]
ws["A2"].value = "SUMMARY"
ws["A2"].font = bold
for i, (label, val) in enumerate(summary_data):
r = 3 + i
ws.cell(row=r, column=1, value=label).font = bold
ws.cell(row=r, column=2, value=val)
# ── Column headers ────────────────────────────────────────────────
hdr_row = 16
headers = [
"Item No", "Item Code", "Description", "Unit", "Quantity",
"BOQ Rate", "Quoted Rate", "SOR Rate", "Zone",
"Diff %", "Status", "Note"
]
for col, h in enumerate(headers, 1):
c = ws.cell(row=hdr_row, column=col, value=h)
c.font = Font(bold=True, color="FFFFFF")
c.fill = fill("2E75B6")
c.alignment = ctr
c.border = bdr
ws.row_dimensions[hdr_row].height = 18
# ── Data rows ────────────────────────────────────────────────────
for idx, r in enumerate(summary.results):
row = hdr_row + 1 + idx
row_data = [
r.item_no, r.item_code, r.description, r.unit, r.quantity,
r.boq_rate, r.quoted_rate, r.sor_rate, r.zone,
f"{r.diff_pct:+.2f}%", r.status, r.note
]
row_fill = fill(r.color.replace("#","").zfill(6))
for col, val in enumerate(row_data, 1):
cell = ws.cell(row=row, column=col, value=val)
cell.fill = row_fill
cell.border = bdr
cell.alignment = left if col == 3 else ctr
ws.row_dimensions[row].height = 15
# ── Column widths ─────────────────────────────────────────────────
widths = [8, 14, 55, 8, 12, 12, 12, 12, 6, 10, 12, 45]
for i, w in enumerate(widths, 1):
ws.column_dimensions[get_column_letter(i)].width = w
# ── Legend ───────────────────────────────────────────────────────
last_row = hdr_row + 1 + len(summary.results) + 2
ws.cell(row=last_row, column=1, value="LEGEND:").font = bold
legend = [
("MATCH", "#92D050", "Rate within +/-5% of SOR"),
("MISMATCH", "#FFFF00", "Rate differs from SOR (needs review)"),
("ABOVE_SOR", "#FF0000", "Quoted HIGHER than SOR β€” justification required"),
("BELOW_SOR", "#FFC000", "Quoted much lower β€” check for data entry error"),
("MISSING", "#D9D9D9", "Item code not found in SOR database"),
]
for i, (status, color, desc) in enumerate(legend):
r = last_row + 1 + i
c = ws.cell(row=r, column=1, value=status)
c.fill = fill(color.replace("#",""))
c.font = bold
c.border = bdr
ws.cell(row=r, column=2, value=desc)
wb.save(output_path)
print(f" [OK] Rate check report: {pathlib.Path(output_path).name}")
return output_path
def generate_summary_txt(summary: CheckSummary, output_path: str) -> str:
"""Generate a plain-text summary report."""
lines = [
"=" * 65,
f" BOQ RATE CHECK SUMMARY",
f" Tender ID : {summary.tender_id}",
f" Zone : {summary.zone}",
"=" * 65,
f" Total items : {summary.total_items}",
f" MATCH : {summary.match_count}",
f" MISMATCH : {summary.mismatch_count}",
f" MISSING : {summary.missing_count}",
f" ABOVE SOR : {summary.above_sor_count} (RED FLAG)",
f" BELOW SOR : {summary.below_sor_count} (CHECK)",
f" Risk Level : {summary.risk_level}",
"-" * 65,
f" Total BOQ Amt : Tk. {summary.total_boq_amount:>15,.0f}",
f" Total Quoted : Tk. {summary.total_quoted_amount:>15,.0f}",
f" Total SOR Amt : Tk. {summary.total_sor_amount:>15,.0f}",
f" Overall Diff : {summary.overall_diff_pct:+.2f}%",
"=" * 65,
"",
"ITEMS REQUIRING ATTENTION:",
"-" * 65,
]
attention = [r for r in summary.results if r.status != "MATCH"]
if attention:
for r in attention:
lines.append(
f" [{r.status:<10}] #{r.item_no} {r.item_code:<14} "
f"Quoted:{r.quoted_rate:>10.2f} SOR:{r.sor_rate:>10.2f} "
f"Diff:{r.diff_pct:+.1f}%"
)
if r.note:
lines.append(f" NOTE: {r.note}")
else:
lines.append(" All items MATCH SOR rates.")
lines += ["", "=" * 65]
text = "\n".join(lines)
pathlib.Path(output_path).write_text(text, encoding="utf-8")
print(f" [OK] Summary report: {pathlib.Path(output_path).name}")
return output_path