From 49cc45884627243ac9ccad5465e016a9b7da3a24 Mon Sep 17 00:00:00 2001 From: Thijs Kuipers Date: Fri, 10 Apr 2026 09:17:51 +0200 Subject: [PATCH] added multiple fixes to report generating --- delay-list-skill/REPORT-STYLE.md | 213 +++++++++++ delay-list-skill/SKILL.md | 57 +-- delay-list-skill/generate_report.py | 572 ++++++++++++++++++++++++++++ 3 files changed, 819 insertions(+), 23 deletions(-) create mode 100644 delay-list-skill/REPORT-STYLE.md create mode 100644 delay-list-skill/generate_report.py diff --git a/delay-list-skill/REPORT-STYLE.md b/delay-list-skill/REPORT-STYLE.md new file mode 100644 index 0000000..c4ca45c --- /dev/null +++ b/delay-list-skill/REPORT-STYLE.md @@ -0,0 +1,213 @@ +# Delay List PDF Report — Styling Reference + +This file defines the visual design for all Step 3 PDF reports generated by the delay-list-processor skill. The design follows the Helloprint brand identity. + +--- + +## Brand Colours + +### Primary Palette +| Name | Hex | Usage | +|----------------|-----------|----------------------------------------------------------| +| Grochimaru | `#D9D909` | Accent highlight (sparingly) | +| Snowflake | `#F0F0F0` | Alternating table row background, light backgrounds | +| Charmed Green | `#008539` | Primary brand green — cover title block background, section headers, HRFlowable dividers | +| Link Green | `#049E46` | Links, secondary green accents | +| Theme Black | `#022B07` | Primary text colour, dark table header backgrounds | + +### Secondary Palette +| Name | Hex | Usage | +|----------------|-----------|----------------------------------------------------------| +| Heroic Red | `#D64545` | Internal Issues section header, error/alert indicators | +| Honey Teriyaki | `#F36D13` | Review Needed section header, warning indicators | +| Ripe Mango | `#F0C225` | Flyeralarm/Probo manual action section headers | +| Hampton Beach | `#9D6639` | Reserved (future use) | +| Seaside | `#67A5B1` | Other Statuses / Shipped section header, info indicators | + +### Derived Colours +| Purpose | Hex | Notes | +|----------------------------|-----------|------------------------------------------| +| In Production header | `#008539` | Uses Charmed Green (on track = green) | +| Table grid lines | `#CCCCCC` | Neutral thin grid | +| Table alt row | `#F0F0F0` | Snowflake | +| Table header text | `#FFFFFF` | White on dark backgrounds | +| Body text | `#022B07` | Theme Black | +| Page header/footer text | `#666666` | Muted grey | +| Header rule line | `#CCCCCC` | Below page header | + +--- + +## Typography + +**Font family:** Inter (Regular and Bold) + +Since `reportlab` does not include Inter by default, download and register `Inter-Regular.ttf` and `Inter-Bold.ttf` before generating the PDF. Fall back to Helvetica only if Inter cannot be loaded. + +```python +# Font registration example +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +try: + pdfmetrics.registerFont(TTFont('Inter', 'Inter-Regular.ttf')) + pdfmetrics.registerFont(TTFont('Inter-Bold', 'Inter-Bold.ttf')) + FONT = 'Inter' + FONT_BOLD = 'Inter-Bold' +except: + FONT = 'Helvetica' + FONT_BOLD = 'Helvetica-Bold' +``` + +### Font Sizes +| Element | Font | Size | Style | +|----------------------------|---------|------|-------| +| Cover title | Bold | 28pt | White on Charmed Green | +| Cover subtitle | Regular | 14pt | White on Charmed Green | +| Cover date | Regular | 12pt | White on Charmed Green | +| Section heading (e.g. "Section 1 — HubSpot Analysis") | Bold | 16pt | Theme Black | +| Subsection heading (e.g. "1.1 Internal Issues") | Bold | 13pt | Theme Black | +| Supplier heading (e.g. "Oakdene Services — 15 order(s)") | Bold | 13pt | Theme Black | +| Table header cells | Bold | 8pt | White | +| Table body cells | Regular | 7.5pt | Theme Black | +| Email template text | Regular | 9pt | Theme Black | +| Page header | Regular | 8pt | #666666 | +| Page footer | Regular | 8pt | #666666 | +| Stat card number | Bold | 28pt | Theme Black | +| Stat card label | Regular | 9pt | #666666 | +| Disclaimer / caveat text | Regular | 8pt | Italic, #666666 | + +--- + +## Page Layout + +- **Page size:** A4 (595.27 x 841.89 pt) +- **Margins:** 18mm left/right, 20mm top (to accommodate header), 18mm bottom +- **Header:** "Helloprint | Delay List Step 3 Report | DD Mon YYYY" — 8pt, #666666, with a thin rule line (#CCCCCC) below +- **Footer:** Left: "Confidential - Helloprint Operations" — Right: "Page X" — 8pt, #666666 + +--- + +## Cover Page (Page 1) + +1. **Title block** — Full-width rectangle with `#008539` (Charmed Green) background + - "Delay List Report" — 28pt Bold, white + - "Step 3: Supplier Outreach Analysis" — 14pt Regular, white + - "DD Mon YYYY" — 12pt Regular, white + - Padding: 20pt top/bottom, 18mm left + +2. **Summary stat cards** — 2×4 table grid below the title block + - Each cell: large number (28pt Bold, Theme Black) + label below (9pt Regular, #666666) + - Cell borders: thin #CCCCCC + - Cards: Total Orders | HubSpot Hits | Need Outreach | Internal Issues | Masterprint (Skip) | Flyeralarm (Manual) | Probo (Manual) | Suppliers to Contact + +3. **Caveat note** — Italic 8pt disclaimer about HubSpot search scope + +--- + +## Section 1 — HubSpot Analysis + +Section heading: "Section 1 — HubSpot Analysis" (16pt Bold, Theme Black) + +### Subsections with colour-coded table headers: + +| Subsection | Header background | Header text | +|------------|-------------------|-------------| +| 1.1 Internal Issues | `#D64545` (Heroic Red) | White | +| 1.2 In Production / On Track | `#008539` (Charmed Green) | White | +| 1.3 Shipped | `#67A5B1` (Seaside) | White | +| 1.4 Other Statuses | `#67A5B1` (Seaside) | White | +| 1.5 Review Needed | `#F36D13` (Honey Teriyaki) | White | + +### Table columns: +| Column | Width % | Content | +|--------|---------|---------| +| Ticket ID | 18% | Real `hs_ticket_id` | +| Order Detail ID | 15% | `id_order_detail` | +| Order ID | 12% | `id_order` | +| Details | 55% | Ticket subject / summary (use Paragraph flowable for wrapping) | + +- Alternating rows: white / `#F0F0F0` +- Grid: 0.5pt `#CCCCCC` +- Cell padding: 4pt + +--- + +## Section 2 — Manual Action Required + +Section heading: "Section 2 — Manual Action Required" (16pt Bold, Theme Black) + +### Flyeralarm +- Subsection heading: "Flyeralarm (N orders)" — 13pt Bold +- Instruction text (italic, 9pt): "These orders must be handled via the Flyeralarm supplier portal (case creation). Do NOT send outreach emails." +- Table with `#F0C225` (Ripe Mango) header background, white text +- Columns: Order Detail ID | Order ID | Supplier | Product | Target Dispatch + +### Probo +- Subsection heading: "Probo (N orders)" — 13pt Bold +- Instruction text (italic, 9pt): "Verify actual production status in the Probo portal. Probo delay emails received for some orders — follow up for new dispatch date." +- Table with `#F0C225` (Ripe Mango) header background, white text +- Columns: Order Detail ID | Order ID | Product | Target Dispatch | **Delay Email?** (Yes/No — whether a Probo delay email was found in HubSpot) + +### Masterprint +- Subsection heading: "Masterprint (N orders)" — 13pt Bold +- Text only (italic, 9pt): "Masterprint tracking is automated. No outreach needed." + +--- + +## Section 3 — Supplier Outreach Emails + +Section heading: "Section 3 — Supplier Outreach Emails" (16pt Bold, Theme Black) +Subtitle: "N orders across M suppliers require outreach." (9pt Regular, #666666) + +### Per-supplier block: + +1. **Divider:** HRFlowable, 1pt, `#008539` (Charmed Green) +2. **Supplier heading:** "Supplier Name — N order(s)" (13pt Bold, Theme Black) +3. **Email opening:** "Hi Partner," (9pt Regular) +4. **Email body:** Template text (9pt Regular) +5. **Order table:** + - Header: `#022B07` (Theme Black) background, white text, 8pt Bold + - Columns and widths: + +| Column | Width % | Content | Format | +|--------|---------|---------|--------| +| Order | 20% | `{id_order}-{id_order_detail}` | e.g. "5831128-9215018" | +| Target Dispatch Date | 15% | Agreed dispatch date | DD Mon YYYY | +| Product | 25% | Product type/SKU | As from API | +| Supplier Order | 15% | Supplier's order number | Value or "-" | +| Carrier | 25% | Intended carrier | As from API | + +6. **Email closing:** "Please could you provide..." + "Thank you..." (9pt Regular) +7. **Spacer:** 12pt before next supplier + +### Important implementation notes: +- Do NOT wrap supplier email blocks in a single-cell Table — use individual Paragraph + Table + Spacer flowables so reportlab can split across page breaks +- Use `KeepTogether` only for supplier blocks with ≤5 orders +- Alternating rows: white / `#F0F0F0` +- Grid: 0.5pt `#CCCCCC` + +--- + +## Inter Font Download + +To use Inter, download from Google Fonts before generating: + +```python +import urllib.request, os + +FONT_DIR = '/tmp/fonts' +os.makedirs(FONT_DIR, exist_ok=True) + +INTER_REGULAR = f'{FONT_DIR}/Inter-Regular.ttf' +INTER_BOLD = f'{FONT_DIR}/Inter-Bold.ttf' + +if not os.path.exists(INTER_REGULAR): + urllib.request.urlretrieve( + 'https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf', + INTER_REGULAR + ) +# Note: Inter is a variable font. For reportlab, you may need the static builds: +# https://github.com/rsms/inter/releases — use Inter-Regular.ttf and Inter-Bold.ttf +``` + +If the download fails (network restrictions), fall back to Helvetica silently. diff --git a/delay-list-skill/SKILL.md b/delay-list-skill/SKILL.md index d5acd81..2b429d6 100644 --- a/delay-list-skill/SKILL.md +++ b/delay-list-skill/SKILL.md @@ -361,50 +361,61 @@ Generate at end of processing and save as a file: ## **PDF Report Generation** -After completing Step 3, generate a professional PDF report using `reportlab`. The PDF serves as the primary deliverable for the traffic team to review and act on. Install with `pip install reportlab --break-system-packages` if not available. +After completing Step 3, generate the PDF report by running **`generate_report.py`** (in this same skill folder). This script contains all layout, branding, classification logic, and table formatting. Do NOT write PDF generation code from scratch — always use this script. -### PDF Structure +```bash +python generate_report.py [--date "DD Month YYYY"] +``` -The report has three main sections, each starting on a new page: +**Example:** +```bash +python generate_report.py s3_today.json hs_tickets.json delay-list-step3-report-2026-04-10.pdf --date "10 April 2026" +``` + +The script requires two input JSON files: +- **orders_json** — the Step 3 filtered orders (output of the delay list API filtering in Step 3 above). List of order objects with keys: `id_order_detail`, `id_order`, `supplier_name`, `product_tid`, `despatch_date_target`, `supplier_order_number`, `carriername`. +- **hubspot_tickets_json** — merged HubSpot ticket search results from the mandatory 3-step search. List of ticket objects with nested `properties` containing: `hs_ticket_id`, `hp_order_line_id`, `subject`, `content`, `createdate`. + +The script handles all classification (HubSpot 5-category split, Flyeralarm/Probo/Masterprint separation, Probo delay email detection, outreach grouping) and generates the branded PDF automatically. + +> **Styling reference:** All colours, fonts, layout specs, table column widths, and section structure are also documented in **`REPORT-STYLE.md`** (in this same skill folder) for reference. The script implements these specs. If you need to modify the visual design, update both the script and REPORT-STYLE.md. + +### PDF Structure & Content + +The report has three main sections, each starting on a new page. The exact visual design (colours, fonts, column widths, spacing) is specified in `REPORT-STYLE.md`. Below is the content specification: **Page 1 — Cover & Summary** -- Title block with dark background: "Delay List Report — Step 3: Supplier Outreach Analysis" + date -- Summary stat cards in a grid layout showing: Total Orders, HubSpot Hits, Need Outreach, Internal Issues, Masterprint (Skipped), Flyeralarm (Manual), Probo (Manual), Suppliers to Contact +- Title block: "Delay List Report" + "Step 3: Supplier Outreach Analysis" + date +- Summary stat cards (2×4 grid): Total Orders, HubSpot Hits, Need Outreach, Internal Issues, Masterprint (Skipped), Flyeralarm (Manual), Probo (Manual), Suppliers to Contact - Caveat note about HubSpot search scope limitations **Section 1 — HubSpot Analysis** Colour-coded tables categorising orders with existing communication: -- **1.1 Internal Issues** (red header) — orders blocked by artwork errors, API failures, retry limits, price disputes. These need Helloprint action, not supplier outreach. -- **1.2 In Production / On Track / Reprints** (green header) — orders confirmed in production or with reprints underway. No outreach needed. -- **1.3 Other Statuses** (blue header) — shipped, awaiting approval, customer issues, unclear status. -- **1.4 HubSpot Tickets Found — Review Needed** (orange header) — tickets exist but need manual review to determine action. +- **1.1 Internal Issues** — orders blocked by artwork errors, API failures, retry limits, price disputes. These need Helloprint action, not supplier outreach. +- **1.2 In Production / On Track** — orders confirmed in production with delay reason, reprints underway. No outreach needed. +- **1.3 Shipped** — orders where tracking/shipping confirmation was found. +- **1.4 Other Statuses** — awaiting approval, customer issues, unclear status. +- **1.5 HubSpot Tickets Found — Review Needed** — tickets exist but need manual review to determine action. Each table has 4 columns: **Ticket ID** (the real HubSpot `hs_ticket_id`), **Order Detail ID** (`id_order_detail`), **Order ID** (`id_order`), **Details** (summary of the issue/status). The Ticket ID must be the actual HubSpot ticket identifier — do NOT use the `id_order_detail` as Ticket ID. When performing the HubSpot search, always store the returned `hs_ticket_id` alongside the matched `hp_order_line_id` / `hp_order_id` so they can be rendered correctly in the report. Use Paragraph flowables for the Details column to allow text wrapping. **Section 2 — Manual Action Required** -- **Flyeralarm** — order table with note that portal case creation is required -- **Probo** — order table with note to verify in Probo portal +- **Flyeralarm** — order table with instruction text, columns: Order Detail ID, Order ID, Supplier, Product, Target Dispatch +- **Probo** — order table with instruction text, columns: Order Detail ID, Order ID, Product, Target Dispatch, **Delay Email?** (Yes/No — whether a Probo delay email was found in HubSpot) - **Masterprint** — count + note that tracking is automated **Section 3 — Supplier Outreach Emails** For each supplier (sorted by order count descending): -- Supplier heading with order count +- Supplier heading with order count (format: "Supplier Name — N order(s)") - Draft email using the standard email template (see Email Template section above) - Embedded order table within the email block -### PDF Styling +### Implementation Notes -Use these design principles for a clean, professional look: +These notes apply if you ever need to modify `generate_report.py`: -- **Colours**: Dark navy (#1a1a2e) for title block, blue (#0f3460) for section headers, red (#e94560) for internal issues, green (#27ae60) for on-track, orange (#f39c12) for review-needed, blue (#2980b9) for other statuses -- **Fonts**: Helvetica family (Bold for headers, Regular for body) -- **Tables**: Dark header row with white text, alternating row backgrounds, thin grid lines (#bdc3c7), compact padding (2-3pt) -- **Page**: A4, 18mm left/right margins, header line with "Helloprint | Delay List Step 3 Report | [date]", footer with "Confidential - Helloprint Operations" and page number -- **Email blocks**: Separated by horizontal rules (blue #2980b9), not wrapped in a single table cell (large supplier email blocks with many orders must be able to split across pages) - -### Implementation Note - -Do NOT wrap email blocks in a single-cell Table flowable — suppliers with many orders (e.g. Yurchak with 40+ orders) will overflow a single page and cause a LayoutError. Instead, use individual flowables (Paragraph, Table, Spacer) separated by HRFlowable dividers. This allows reportlab to split content naturally across page breaks. +- Do NOT wrap email blocks in a single-cell Table flowable — suppliers with many orders will overflow a single page and cause a LayoutError. Use individual flowables separated by HRFlowable dividers. `KeepTogether` only for supplier blocks with ≤5 orders. +- All table cell values must be wrapped in `Paragraph` flowables (via the `w()` helper) to enable automatic line-breaking. Plain strings will overflow their columns. --- diff --git a/delay-list-skill/generate_report.py b/delay-list-skill/generate_report.py new file mode 100644 index 0000000..e39816d --- /dev/null +++ b/delay-list-skill/generate_report.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +generate_report.py — Step 3 Delay List PDF Report Generator +Helloprint branded, following REPORT-STYLE.md specs. + +Usage: + python generate_report.py [--date "DD Month YYYY"] + +Arguments: + orders_json Path to Step 3 filtered orders JSON (output of delay list API filtering) + hubspot_tickets_json Path to HubSpot ticket search results JSON (batch 0 / merged results) + output_pdf Path for the generated PDF report + --date Report date string (default: today) + +Example: + python generate_report.py s3_today.json hs_tickets.json report.pdf --date "10 April 2026" + +The input JSON files must have the following structure: +- orders_json: list of order objects with keys: id_order_detail, id_order, supplier_name, + product_tid, despatch_date_target, supplier_order_number, carriername +- hubspot_tickets_json: list of HubSpot ticket objects with nested 'properties' containing: + hs_ticket_id, hp_order_line_id, subject, content, createdate, hs_pipeline_stage +""" +import json, sys, os, argparse, urllib.request +from datetime import datetime +from collections import defaultdict +from pathlib import Path + +try: + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import mm + from reportlab.lib import colors + from reportlab.platypus import ( + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, + KeepTogether, PageBreak, HRFlowable + ) + from reportlab.lib.enums import TA_LEFT, TA_CENTER + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont +except ImportError: + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab", "--break-system-packages"]) + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import mm + from reportlab.lib import colors + from reportlab.platypus import ( + SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, + KeepTogether, PageBreak, HRFlowable + ) + from reportlab.lib.enums import TA_LEFT, TA_CENTER + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + +# ═══════════════════════════════════════════════════════════════════ +# Font setup — try Inter (Helloprint brand), fall back to Helvetica +# ═══════════════════════════════════════════════════════════════════ +FONT_DIR = '/tmp/fonts' +os.makedirs(FONT_DIR, exist_ok=True) +try: + for variant, url in [ + ('Inter-Regular', 'https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Regular.ttf'), + ('Inter-Bold', 'https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Bold.ttf'), + ]: + fp = f'{FONT_DIR}/{variant}.ttf' + if not os.path.exists(fp): + urllib.request.urlretrieve(url, fp) + pdfmetrics.registerFont(TTFont(variant, fp)) + FONT = 'Inter-Regular' + FONT_B = 'Inter-Bold' +except Exception: + FONT = 'Helvetica' + FONT_B = 'Helvetica-Bold' + + +# ═══════════════════════════════════════════════════════════════════ +# Brand colours — from REPORT-STYLE.md +# ═══════════════════════════════════════════════════════════════════ +CHARMED_GREEN = colors.HexColor("#008539") +LINK_GREEN = colors.HexColor("#049E46") +THEME_BLACK = colors.HexColor("#022B07") +SNOWFLAKE = colors.HexColor("#F0F0F0") +HEROIC_RED = colors.HexColor("#D64545") +HONEY_TERIYAKI = colors.HexColor("#F36D13") +RIPE_MANGO = colors.HexColor("#F0C225") +SEASIDE = colors.HexColor("#67A5B1") +GRID_GRAY = colors.HexColor("#CCCCCC") +MUTED_GRAY = colors.HexColor("#666666") +WHITE = colors.white + +PAGE_W, PAGE_H = A4 +CONTENT_W = PAGE_W - 36 * mm # 18 mm margins each side + +# Supplier sets +FLYERALARM_NAMES = {"Flyeralarm", "Flyeralarm BE", "Flyeralarm FR", "Flyeralarm DE"} +PROBO_NAMES = {"Probo"} +MASTERPRINT_NAMES = {"Masterprint XL"} + +# Informational ticket subject patterns (case-insensitive) +INFO_PATTERNS = [ + 'order confirmation', 'bestelling ontvangen', 'auftrag', + 'being printed', 'ready to print', 'thank you for your order', + 'print proof update', 'bestel ontwerp', 'drukproef', 'reminder drukproef', + 'confirmation de projet', 'solo midocean order confirmation', + 'artwork received', 'quotation', 'o- 003-fsse', '[01840023]', + 'good news! your', +] + + +# ═══════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════ +def fmt_date(d): + """Format a date string to 'DD Mon YYYY'.""" + if not d: + return "N/A" + for f in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]: + try: + return datetime.strptime(d[:19], f).strftime("%d %b %Y") + except ValueError: + pass + return d[:10] + + +def load_json(fp): + with open(fp, encoding="utf-8") as f: + return json.load(f) + + +def is_informational(subj: str) -> bool: + sl = subj.lower() + return any(pat in sl for pat in INFO_PATTERNS) + + +# ═══════════════════════════════════════════════════════════════════ +# Paragraph styles +# ═══════════════════════════════════════════════════════════════════ +def _style(name, font=None, size=9, color=THEME_BLACK, align=TA_LEFT, + space_before=0, space_after=0, leading=None, bold=False): + fn = (FONT_B if bold else font) or FONT + return ParagraphStyle(name, fontName=fn, fontSize=size, textColor=color, + alignment=align, spaceBefore=space_before, + spaceAfter=space_after, leading=leading or size * 1.3) + + +S_BODY = _style('Body', size=9) +S_SECTION = _style('Section', size=16, bold=True, space_before=12, space_after=8) +S_SUBSEC = _style('SubSec', size=13, bold=True, space_before=10, space_after=6) +S_SUPPLIER = _style('Supplier', size=13, bold=True, space_before=6, space_after=4) +S_EMAIL = _style('Email', size=9, leading=13) +S_CAVEAT = _style('Caveat', size=8, color=MUTED_GRAY) +S_STAT_NUM = _style('StatNum', size=28, bold=True, align=TA_CENTER) +S_STAT_LBL = _style('StatLbl', size=9, color=MUTED_GRAY, align=TA_CENTER) +S_COVER_TITLE = _style('CoverTitle', size=28, bold=True, color=WHITE) +S_COVER_SUB = _style('CoverSub', size=14, color=WHITE) +S_COVER_DATE = _style('CoverDate', size=12, color=WHITE) +S_SUBTITLE = _style('Subtitle', size=9, color=MUTED_GRAY, space_after=8) +S_ITALIC = _style('Italic', size=9, color=MUTED_GRAY) +S_TABLE_WRAP = _style('TableWrap', size=7.5, leading=10) +S_HDR_CELL = _style('HdrCell', size=8, bold=True, color=WHITE) + + +def p(text, style): + """Shortcut: create a Paragraph.""" + return Paragraph(text, style) + + +def w(text, style=None): + """Wrap any cell value in a Paragraph for automatic line-breaking.""" + if isinstance(text, Paragraph): + return text + return Paragraph(str(text), style or S_TABLE_WRAP) + + +# ═══════════════════════════════════════════════════════════════════ +# Table builder +# ═══════════════════════════════════════════════════════════════════ +def section_table(header_bg, col_widths_pct, header_labels, rows): + """Colour-coded table with alternating rows and Paragraph-wrapped headers.""" + cw = [CONTENT_W * pct / 100 for pct in col_widths_pct] + hdr = [Paragraph(lbl, S_HDR_CELL) for lbl in header_labels] + data = [hdr] + rows + t = Table(data, colWidths=cw, repeatRows=1) + cmds = [ + ('BACKGROUND', (0, 0), (-1, 0), header_bg), + ('TEXTCOLOR', (0, 0), (-1, 0), WHITE), + ('FONTNAME', (0, 0), (-1, 0), FONT_B), + ('FONTSIZE', (0, 0), (-1, 0), 8), + ('FONTNAME', (0, 1), (-1, -1), FONT), + ('FONTSIZE', (0, 1), (-1, -1), 7.5), + ('TEXTCOLOR', (0, 1), (-1, -1), THEME_BLACK), + ('GRID', (0, 0), (-1, -1), 0.5, GRID_GRAY), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ALIGN', (0, 0), (-1, 0), 'LEFT'), + ] + for i in range(1, len(data)): + if i % 2 == 0: + cmds.append(('BACKGROUND', (0, i), (-1, i), SNOWFLAKE)) + t.setStyle(TableStyle(cmds)) + return t + + +# ═══════════════════════════════════════════════════════════════════ +# Data classification +# ═══════════════════════════════════════════════════════════════════ +def classify_orders(orders, tickets_raw): + """ + Classify orders into HubSpot categories + supplier groups. + Returns a dict with all classified lists. + """ + detail_map = {str(o['id_order_detail']): o for o in orders} + + # Map tickets to detail IDs + detail_tickets = defaultdict(list) + for t in tickets_raw: + pr = t.get('properties', {}) + lid = str(pr.get('hp_order_line_id', '')) + detail_tickets[lid].append(pr) + + # HubSpot categories + internal, in_prod, shipped, other_status, review_needed = [], [], [], [], [] + actionable_dids = set() + + for did, tix_list in detail_tickets.items(): + if did not in detail_map: + continue + order = detail_map[did] + + # Filter out Aircall + informational tickets + act_tix = [t for t in tix_list + if 'aircall' not in (t.get('subject', '') or '').lower() + and not is_informational(t.get('subject', '') or '')] + if not act_tix: + continue + + best = max(act_tix, key=lambda x: x.get('createdate', '')) + bs = (best.get('subject', '') or '').lower() + bc = (best.get('content', '') or '').lower() + entry = (order, best, len(tix_list)) + actionable_dids.add(did) + + if any(k in bs for k in ['creation failed', 'artwork rejected', 'artwork query', + 'error in api', 'artwork refusal']): + internal.append(entry) + elif any(k in bs for k in ['delay order', 'delayed delivery', 'vertraging', + 'update to your expected shipping date', 'not sent', 'urgent']): + in_prod.append(entry) + elif any(k in bs for k in ['on the way', 'shipped', 'tracking']): + shipped.append(entry) + elif 'cancel' in bc[:200]: + other_status.append(entry) + elif 'problème technique' in bc[:200] or 'technical' in bc[:200]: + internal.append(entry) + else: + review_needed.append(entry) + + # Supplier-specific groups + fly_orders = [o for o in orders if o.get('supplier_name', '') in FLYERALARM_NAMES] + probo_orders = [o for o in orders if o.get('supplier_name', '') in PROBO_NAMES] + master_orders = [o for o in orders if o.get('supplier_name', '') in MASTERPRINT_NAMES] + + # Probo delay email detection + probo_delay_dids = set() + for o in probo_orders: + did = str(o['id_order_detail']) + for t in detail_tickets.get(did, []): + s = (t.get('subject', '') or '').lower() + if 'vertraging' in s or 'delay' in s: + probo_delay_dids.add(did) + + # Remove Flyeralarm orders that are already in actionable HubSpot categories + fly_orders = [o for o in fly_orders if str(o['id_order_detail']) not in actionable_dids] + + # Outreach: not special supplier + not actionable HubSpot hit + special_dids = set(str(o['id_order_detail']) for o in fly_orders + probo_orders + master_orders) + outreach = [o for o in orders + if str(o['id_order_detail']) not in actionable_dids + and str(o['id_order_detail']) not in special_dids] + + outreach_by_sup = defaultdict(list) + for o in outreach: + outreach_by_sup[o.get('supplier_name', 'Unknown')].append(o) + sorted_suppliers = sorted(outreach_by_sup.items(), key=lambda x: -len(x[1])) + + return { + 'internal': internal, + 'in_prod': in_prod, + 'shipped': shipped, + 'other_status': other_status, + 'review_needed': review_needed, + 'actionable_dids': actionable_dids, + 'fly_orders': fly_orders, + 'probo_orders': probo_orders, + 'master_orders': master_orders, + 'probo_delay_dids': probo_delay_dids, + 'outreach': outreach, + 'sorted_suppliers': sorted_suppliers, + } + + +# ═══════════════════════════════════════════════════════════════════ +# PDF generation +# ═══════════════════════════════════════════════════════════════════ +def generate_pdf(orders, cls, report_date, output_path): + """ + Generate the branded PDF report. + + Args: + orders: list of order dicts + cls: classification dict (from classify_orders) + report_date: date string for the report (e.g. "10 April 2026") + output_path: file path for the output PDF + """ + internal = cls['internal'] + in_prod = cls['in_prod'] + shipped = cls['shipped'] + other_status = cls['other_status'] + review_needed = cls['review_needed'] + actionable_dids = cls['actionable_dids'] + fly_orders = cls['fly_orders'] + probo_orders = cls['probo_orders'] + master_orders = cls['master_orders'] + probo_delay_dids = cls['probo_delay_dids'] + outreach = cls['outreach'] + sorted_suppliers = cls['sorted_suppliers'] + + # ── Page template ── + def page_template(canvas, doc): + canvas.saveState() + canvas.setFont(FONT, 8) + canvas.setFillColor(MUTED_GRAY) + canvas.drawString(18 * mm, PAGE_H - 14 * mm, + f"Helloprint | Delay List Step 3 Report | {report_date}") + canvas.setStrokeColor(GRID_GRAY) + canvas.setLineWidth(0.5) + canvas.line(18 * mm, PAGE_H - 15 * mm, PAGE_W - 18 * mm, PAGE_H - 15 * mm) + canvas.drawString(18 * mm, 10 * mm, "Confidential – Helloprint Operations") + canvas.drawRightString(PAGE_W - 18 * mm, 10 * mm, f"Page {doc.page}") + canvas.restoreState() + + # ── HubSpot row builder ── + def hs_rows(items): + rows = [] + for order, ticket, total_cnt in items: + tid = ticket.get('hs_ticket_id', 'N/A') + did = order.get('id_order_detail', 'N/A') + oid = order.get('id_order', 'N/A') + subj = ticket.get('subject', '')[:80] + sup = order.get('supplier_name', '') + extra = f" (+{total_cnt - 1} more)" if total_cnt > 1 else "" + detail_text = f"{sup}
{subj}{extra}" + rows.append([w(tid), w(did), w(oid), p(detail_text, S_TABLE_WRAP)]) + return rows + + story = [] + + # ━━ COVER PAGE ━━ + title_data = [ + [p("Delay List Report", S_COVER_TITLE)], + [p("Step 3: Supplier Outreach Analysis", S_COVER_SUB)], + [p(report_date, S_COVER_DATE)], + ] + title_table = Table(title_data, colWidths=[CONTENT_W]) + title_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), CHARMED_GREEN), + ('TOPPADDING', (0, 0), (0, 0), 20), + ('BOTTOMPADDING', (-1, -1), (-1, -1), 20), + ('TOPPADDING', (0, 1), (0, 1), 0), + ('BOTTOMPADDING', (0, 1), (0, 1), 4), + ('TOPPADDING', (0, 2), (0, 2), 0), + ('LEFTPADDING', (0, 0), (-1, -1), 18), + ])) + story.append(title_table) + story.append(Spacer(1, 20)) + + # Stat cards — 2×4 grid + hs_hits = len(actionable_dids) + stats = [ + ("Total Orders", len(orders)), + ("HubSpot Hits", hs_hits), + ("Need Outreach", len(outreach)), + ("Internal Issues", len(internal)), + ("Masterprint (Skip)", len(master_orders)), + ("Flyeralarm (Manual)", len(fly_orders)), + ("Probo (Manual)", len(probo_orders)), + ("Suppliers to Contact", len(sorted_suppliers)), + ] + for i in range(0, 8, 2): + row = [] + for j in range(2): + lbl, val = stats[i + j] + cell = [p(f"{val}", S_STAT_NUM), p(lbl, S_STAT_LBL)] + row.append(cell) + card = Table([row], colWidths=[CONTENT_W / 2, CONTENT_W / 2]) + card.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), 0.5, GRID_GRAY), + ('INNERGRID', (0, 0), (-1, -1), 0.5, GRID_GRAY), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ])) + story.append(card) + + story.append(Spacer(1, 12)) + story.append(p("Note: HubSpot search covers batch IN-filter on hp_order_line_id (step 1). " + "Steps 2 and 3 (hp_order_id IN + individual query) may reveal additional " + "tickets not shown here.", S_CAVEAT)) + + # ━━ SECTION 1 — HUBSPOT ANALYSIS ━━ + story.append(PageBreak()) + story.append(p("Section 1 — HubSpot Analysis", S_SECTION)) + + HS_COLS = [18, 15, 12, 55] + HS_HDR = ["Ticket ID", "Order Detail ID", "Order ID", "Details"] + + for title, items, color in [ + ("1.1 Internal Issues", internal, HEROIC_RED), + ("1.2 In Production / On Track", in_prod, CHARMED_GREEN), + ("1.3 Shipped", shipped, SEASIDE), + ("1.4 Other Statuses", other_status, SEASIDE), + ("1.5 Review Needed", review_needed, HONEY_TERIYAKI), + ]: + story.append(p(title, S_SUBSEC)) + if items: + story.append(section_table(color, HS_COLS, HS_HDR, hs_rows(items))) + else: + story.append(p(f"No orders in this category.", S_ITALIC)) + story.append(Spacer(1, 10)) + + # ━━ SECTION 2 — MANUAL ACTION REQUIRED ━━ + story.append(PageBreak()) + story.append(p("Section 2 — Manual Action Required", S_SECTION)) + + # Flyeralarm + story.append(p(f"Flyeralarm ({len(fly_orders)} orders)", S_SUBSEC)) + story.append(p("These orders must be handled via the Flyeralarm supplier portal " + "(case creation). Do NOT send outreach emails.", S_ITALIC)) + story.append(Spacer(1, 4)) + if fly_orders: + fly_rows = [[w(o.get('id_order_detail', '')), w(o.get('id_order', '')), + w(o.get('supplier_name', '')), + w(o.get('product_tid', '')[:30]), + w(fmt_date(o.get('despatch_date_target', '')))] + for o in fly_orders] + story.append(section_table(RIPE_MANGO, [15, 15, 20, 30, 20], + ["Order Detail ID", "Order ID", "Supplier", "Product", "Target Dispatch"], fly_rows)) + story.append(Spacer(1, 12)) + + # Probo + story.append(p(f"Probo ({len(probo_orders)} orders)", S_SUBSEC)) + story.append(p("Verify actual production status in the Probo portal. Probo delay emails " + "received for some orders — follow up for new dispatch date.", S_ITALIC)) + story.append(Spacer(1, 4)) + if probo_orders: + probo_rows = [] + for o in probo_orders: + did = str(o.get('id_order_detail', '')) + probo_rows.append([ + w(did), w(o.get('id_order', '')), + w(o.get('product_tid', '')[:30]), + w(fmt_date(o.get('despatch_date_target', ''))), + w("Yes" if did in probo_delay_dids else "No") + ]) + story.append(section_table(RIPE_MANGO, [15, 15, 30, 22, 18], + ["Order Detail ID", "Order ID", "Product", "Target Dispatch", "Delay Email?"], + probo_rows)) + story.append(Spacer(1, 12)) + + # Masterprint + story.append(p(f"Masterprint ({len(master_orders)} orders)", S_SUBSEC)) + story.append(p("Masterprint tracking is automated. No outreach needed.", S_ITALIC)) + + # ━━ SECTION 3 — SUPPLIER OUTREACH EMAILS ━━ + story.append(PageBreak()) + story.append(p("Section 3 — Supplier Outreach Emails", S_SECTION)) + story.append(p(f"{len(outreach)} orders across {len(sorted_suppliers)} suppliers " + "require outreach.", S_SUBTITLE)) + + EMAIL_OPEN = "Hi Partner," + EMAIL_BODY = ("The orders listed below were due to be dispatched on the agreed target " + "date, however they appear to not have been completed or sent yet.") + EMAIL_CLOSE_1 = ("Please could you provide an urgent update on the current status of these " + "orders and confirm when they will be ready for dispatch?") + EMAIL_CLOSE_2 = "Thank you, and we look forward to your prompt response." + + OUT_COLS = [20, 15, 25, 15, 25] + OUT_HDR = ["Order", "Target Dispatch", "Product", "Supplier Order", "Carrier"] + + for supplier, sup_orders in sorted_suppliers: + story.append(HRFlowable(width="100%", thickness=1, color=CHARMED_GREEN, + spaceBefore=6, spaceAfter=6)) + story.append(p(f"{supplier} — {len(sup_orders)} order(s)", S_SUPPLIER)) + story.append(p(EMAIL_OPEN, S_EMAIL)) + story.append(Spacer(1, 4)) + story.append(p(EMAIL_BODY, S_EMAIL)) + story.append(Spacer(1, 6)) + + rows = [[w(f"{o.get('id_order', '')}-{o.get('id_order_detail', '')}"), + w(fmt_date(o.get('despatch_date_target', ''))), + w(o.get('product_tid', '')[:35]), + w(o.get('supplier_order_number', '') or '-'), + w(o.get('carriername', '') or '-')] + for o in sup_orders] + + tbl = section_table(THEME_BLACK, OUT_COLS, OUT_HDR, rows) + + if len(sup_orders) <= 5: + story.append(KeepTogether([ + tbl, Spacer(1, 6), + p(EMAIL_CLOSE_1, S_EMAIL), Spacer(1, 4), + p(EMAIL_CLOSE_2, S_EMAIL) + ])) + else: + story.append(tbl) + story.append(Spacer(1, 6)) + story.append(p(EMAIL_CLOSE_1, S_EMAIL)) + story.append(Spacer(1, 4)) + story.append(p(EMAIL_CLOSE_2, S_EMAIL)) + + story.append(Spacer(1, 12)) + + # ── Build PDF ── + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + doc = SimpleDocTemplate(str(output_path), pagesize=A4, + leftMargin=18 * mm, rightMargin=18 * mm, + topMargin=20 * mm, bottomMargin=18 * mm) + doc.build(story, onFirstPage=page_template, onLaterPages=page_template) + + sz = Path(output_path).stat().st_size / 1024 + print(f"PDF generated: {output_path} ({sz:.0f} KB)") + + +# ═══════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════ +def main(): + parser = argparse.ArgumentParser(description="Generate Step 3 Delay List PDF Report") + parser.add_argument("orders_json", help="Path to Step 3 filtered orders JSON") + parser.add_argument("hubspot_json", help="Path to HubSpot ticket results JSON") + parser.add_argument("output_pdf", help="Output PDF file path") + parser.add_argument("--date", default=datetime.now().strftime("%d %B %Y"), + help="Report date (default: today)") + args = parser.parse_args() + + print(f"Loading orders from {args.orders_json}...") + orders = load_json(args.orders_json) + print(f" → {len(orders)} orders") + + print(f"Loading HubSpot tickets from {args.hubspot_json}...") + tickets = load_json(args.hubspot_json) + print(f" → {len(tickets)} tickets") + + print("Classifying orders...") + cls = classify_orders(orders, tickets) + print(f" internal={len(cls['internal'])} in_prod={len(cls['in_prod'])} " + f"shipped={len(cls['shipped'])} other={len(cls['other_status'])} " + f"review={len(cls['review_needed'])}") + print(f" fly={len(cls['fly_orders'])} probo={len(cls['probo_orders'])} " + f"master={len(cls['master_orders'])} outreach={len(cls['outreach'])} " + f"({len(cls['sorted_suppliers'])} suppliers)") + + print(f"Generating PDF for {args.date}...") + generate_pdf(orders, cls, args.date, args.output_pdf) + + +if __name__ == "__main__": + main()