admin / Bid-Sentinel
publicBid Scrape and Tracking Application with AI Capability
Bid-Sentinel / bid-sentinel-v2 / backend / app / reports.py
10572 B · main
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 | """Server-side PDF generation for the analyst Intelligence Report (reportlab).""" from __future__ import annotations import io from datetime import datetime, timezone from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.platypus import ( HRFlowable, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) BRAND = colors.HexColor("#0b3d91") GREY = colors.HexColor("#475569") GREEN = colors.HexColor("#15803d") RED = colors.HexColor("#b91c1c") _ss = getSampleStyleSheet() _title = ParagraphStyle("t", parent=_ss["Title"], textColor=BRAND, fontSize=20, spaceAfter=2) _sub = ParagraphStyle("s", parent=_ss["Normal"], textColor=GREY, fontSize=10, alignment=TA_CENTER, spaceAfter=12) _h1 = ParagraphStyle("h1", parent=_ss["Heading1"], textColor=BRAND, fontSize=13, spaceBefore=12, spaceAfter=5) _body = ParagraphStyle("b", parent=_ss["Normal"], fontSize=9.5, leading=14, spaceAfter=4) _small = ParagraphStyle("sm", parent=_ss["Normal"], fontSize=8, textColor=GREY) def _money(v) -> str: v = v or 0 if v >= 1_000_000: return f"£{v / 1_000_000:.1f}M" if v >= 1_000: return f"£{round(v / 1_000)}k" return f"£{round(v)}" def _table(rows, widths, aligns=None): t = Table(rows, colWidths=widths, hAlign="LEFT") style = [ ("BACKGROUND", (0, 0), (-1, 0), BRAND), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8.5), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f1f5f9")]), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cbd5e1")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("TOPPADDING", (0, 0), (-1, -1), 3.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 3.5), ("LEFTPADDING", (0, 0), (-1, -1), 6), ] for col, align in (aligns or {}).items(): style.append(("ALIGN", (col, 0), (col, -1), align)) t.setStyle(TableStyle(style)) return t def build_management_summary(stats, bidding_list, capability_gaps, cert_gaps) -> bytes: """Concise executive pipeline report: what's tracked, open and being bid on, plus the capability and certification/clearance gaps.""" buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, leftMargin=1.8 * cm, rightMargin=1.8 * cm, topMargin=1.6 * cm, bottomMargin=1.6 * cm, title="Bid Sentinel - Management Summary", author="Bid Sentinel", ) story = [] story.append(Paragraph("Management Summary", _title)) story.append(Paragraph( f"Bid Sentinel • generated {datetime.now(timezone.utc).strftime('%d %b %Y %H:%M UTC')}", _sub)) story.append(HRFlowable(width="100%", thickness=1.5, color=BRAND, spaceAfter=8)) # Pipeline KPIs story.append(Paragraph("Pipeline at a glance", _h1)) wr = "—" if stats["win_rate"] is None else f"{stats['win_rate']}%" story.append(_table( [["Tracked", "Open", "Being bid on", "Pipeline value", "Closing ≤14d", "Win rate", "Value won"], [str(stats["total"]), str(stats["open"]), str(stats["bidding"]), _money(stats["pipeline_value"]), str(stats["closing_soon"]), wr, _money(stats["won_value"])]], [2.1 * cm, 1.6 * cm, 2.4 * cm, 2.9 * cm, 2.4 * cm, 1.9 * cm, 2.3 * cm], )) story.append(Paragraph( "Pipeline value and the ‘being bid on’ count exclude opportunities " "already marked Lost.", _small)) story.append(Spacer(1, 0.3 * cm)) # Pipeline by bid status story.append(Paragraph("Pipeline by bid status", _h1)) rows = [["Bid status", "Opportunities", "Value"]] for s in stats["by_status"]: rows.append([s["label"], str(s["count"]), _money(s["value"])]) story.append(_table(rows, [6 * cm, 4 * cm, 4 * cm], aligns={1: "CENTER", 2: "RIGHT"})) story.append(Spacer(1, 0.3 * cm)) # Opportunities being bid on story.append(Paragraph("Opportunities being bid on", _h1)) rows = [["Opportunity", "Client", "Value", "Closing", "Fit"]] for b in bidding_list: rows.append([ b["title"][:60], b["buyer"][:34], _money(b["value"]), b["closing"], "—" if b["fit"] is None else f"{b['fit']}%", ]) if len(rows) == 1: rows.append(["No opportunities are currently marked as being bid on.", "", "", "", ""]) story.append(_table(rows, [6.2 * cm, 3.6 * cm, 2 * cm, 2.2 * cm, 1.4 * cm], aligns={2: "RIGHT", 3: "CENTER", 4: "CENTER"})) story.append(Spacer(1, 0.3 * cm)) # Capability gaps story.append(Paragraph("Capability gaps — in-demand services not in your profile", _h1)) rows = [["In-demand capability", "Opps", "Total value"]] for g in capability_gaps[:10]: rows.append([g["term"], str(g["opportunities"]), _money(g["total_value"])]) if len(rows) == 1: rows.append(["No gaps detected — your profile covers all tracked demand.", "", ""]) story.append(_table(rows, [9.5 * cm, 2 * cm, 4 * cm], aligns={1: "CENTER", 2: "RIGHT"})) story.append(Spacer(1, 0.3 * cm)) # Cert / clearance gaps story.append(Paragraph("Certifications & clearances — demand vs held", _h1)) rows = [["Requirement", "Required in", "Demand", "Status"]] for c in cert_gaps[:12]: rows.append([c["name"], f"{c['required_count']} opps", f"{c['demand_pct']}%", "Held" if c["held"] else "GAP"]) if len(rows) == 1: rows.append(["No certifications detected across opportunities.", "", "", ""]) story.append(_table(rows, [7 * cm, 3 * cm, 2.5 * cm, 3 * cm], aligns={1: "CENTER", 2: "CENTER", 3: "CENTER"})) story.append(Spacer(1, 0.4 * cm)) story.append(HRFlowable(width="100%", thickness=0.75, color=colors.HexColor("#cbd5e1"), spaceAfter=4)) story.append(Paragraph( "Generated by Bid Sentinel. Figures reflect tracked opportunities at generation time.", _small)) doc.build(story) return buf.getvalue() def build_intelligence_report(summary, capability_gaps, cert_gaps, buyers, winloss, angles) -> bytes: buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, leftMargin=1.8 * cm, rightMargin=1.8 * cm, topMargin=1.6 * cm, bottomMargin=1.6 * cm, title="Bid Sentinel - Intelligence Report", author="Bid Sentinel", ) story = [] story.append(Paragraph("Intelligence Report", _title)) story.append(Paragraph( f"Bid Sentinel • generated {datetime.now(timezone.utc).strftime('%d %b %Y %H:%M UTC')}", _sub)) story.append(HRFlowable(width="100%", thickness=1.5, color=BRAND, spaceAfter=8)) # Executive summary story.append(Paragraph("Executive summary", _h1)) wr = "—" if summary["win_rate"] is None else f"{summary['win_rate']}%" story.append(_table( [["Opportunities", "Pipeline value", "Win rate", "Capability gaps", "Cert gaps", "Target buyers"], [str(summary["opportunities"]), _money(summary["pipeline_value"]), wr, str(summary["capability_gaps"]), str(summary["cert_gaps"]), str(summary["target_buyers"])]], [2.6 * cm, 2.9 * cm, 2 * cm, 3 * cm, 2.3 * cm, 2.8 * cm], )) story.append(Spacer(1, 0.3 * cm)) # Capability gaps story.append(Paragraph("Capability gaps — in-demand services you don't cover", _h1)) rows = [["In-demand capability", "Opps", "Total value"]] for g in capability_gaps[:12]: rows.append([g["term"], str(g["opportunities"]), _money(g["total_value"])]) if len(rows) == 1: rows.append(["No gaps detected — your profile covers all tracked demand.", "", ""]) story.append(_table(rows, [9.5 * cm, 2 * cm, 4 * cm], aligns={1: "CENTER", 2: "RIGHT"})) # Cert / clearance gaps story.append(Paragraph("Certifications & clearances — demand vs held", _h1)) rows = [["Requirement", "Required in", "Demand", "Status"]] for c in cert_gaps[:14]: rows.append([c["name"], f"{c['required_count']} opps", f"{c['demand_pct']}%", "Held" if c["held"] else "GAP"]) if len(rows) == 1: rows.append(["No certifications detected across opportunities.", "", "", ""]) story.append(_table(rows, [7 * cm, 3 * cm, 2.5 * cm, 3 * cm], aligns={1: "CENTER", 2: "CENTER", 3: "CENTER"})) # Top target buyers story.append(Paragraph("Top target buyers", _h1)) rows = [["Client organisation", "Opps", "Value", "Avg fit", "Top services"]] for b in [x for x in buyers if x["is_target"]][:12] or buyers[:12]: rows.append([ b["buyer"], str(b["opportunities"]), _money(b["total_value"]), "—" if b["avg_fit"] is None else f"{b['avg_fit']}%", ", ".join(b["top_services"][:2]) or "—", ]) story.append(_table(rows, [5.5 * cm, 1.6 * cm, 2 * cm, 1.9 * cm, 4.5 * cm], aligns={1: "CENTER", 2: "RIGHT", 3: "CENTER"})) # Win/loss story.append(Paragraph("Win / loss intelligence", _h1)) owr = "—" if winloss["overall_win_rate"] is None else f"{winloss['overall_win_rate']}%" story.append(Paragraph( f"Overall win rate <b>{owr}</b> ({winloss['won']} won, {winloss['lost']} lost). By fit band:", _body)) rows = [["Fit band", "Won", "Lost", "Win rate"]] for band in winloss["by_fit"]: rows.append([band["label"], str(band["won"]), str(band["lost"]), "—" if band["win_rate"] is None else f"{band['win_rate']}%"]) story.append(_table(rows, [5 * cm, 2 * cm, 2 * cm, 2.5 * cm], aligns={1: "CENTER", 2: "CENTER", 3: "CENTER"})) # Sales angles story.append(Paragraph("Recommended sales angles", _h1)) if not angles: story.append(Paragraph("No target buyers identified yet.", _body)) for a in angles: story.append(Paragraph(f"<b>{a['subject']}</b> <font size=7 color='#64748b'>({a['source']})</font>", _body)) story.append(Paragraph(a["angle"], _body)) story.append(Spacer(1, 0.15 * cm)) story.append(Spacer(1, 0.4 * cm)) story.append(HRFlowable(width="100%", thickness=0.75, color=colors.HexColor("#cbd5e1"), spaceAfter=4)) story.append(Paragraph( "Generated by Bid Sentinel from tracked opportunity assessments. Figures reflect data captured at generation time.", _small)) doc.build(story) return buf.getvalue() |