added multiple fixes to report generating

This commit is contained in:
2026-04-10 09:17:51 +02:00
parent 6d5b665789
commit 49cc458846
3 changed files with 819 additions and 23 deletions
+213
View File
@@ -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.
+34 -23
View File
@@ -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 <orders_json> <hubspot_tickets_json> <output_pdf> [--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.
---
+572
View File
@@ -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 <orders_json> <hubspot_tickets_json> <output_pdf> [--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"<b>{sup}</b><br/>{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"<b>{val}</b>", 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("<i>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.</i>", 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"<i>No orders in this category.</i>", 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("<i>These orders must be handled via the Flyeralarm supplier portal "
"(case creation). Do NOT send outreach emails.</i>", 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("<i>Verify actual production status in the Probo portal. Probo delay emails "
"received for some orders — follow up for new dispatch date.</i>", 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("<i>Masterprint tracking is automated. No outreach needed.</i>", 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()