Spaces:
Running
Running
| """ | |
| 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 | |