gigafibre-fsm/scripts/migration/setup_invoice_print_format.py
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

700 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Create / update the custom Print Format "Facture TARGO" on ERPNext.
Design
------
- Letter-size, #10 envelope-window compatible
- 2-column layout: left = client + per-location charges, right = summary + QR
- Items grouped by Sales Invoice Item.service_location (Link → Service Location).
Identical (name, rate) rows within a location are consolidated with a "(xN)"
suffix, matching the reference preview.
- QR code is embedded as base64 data URI via the whitelisted method
`gigafibre_utils.api.invoice_qr_base64`. Depends on the custom app
`gigafibre_utils` being installed on the bench (see /opt/erpnext/custom/
on the ERPNext host). wkhtmltopdf does NOT fetch the QR over HTTP.
- SVG logo paths use inline `fill="#019547"` (wkhtmltopdf / QtWebKit does not
honour `<defs><style>` CSS inside SVG).
Run (inside erpnext-backend-1)
------------------------------
docker cp scripts/migration/setup_invoice_print_format.py \\
erpnext-backend-1:/tmp/setup_invoice_print_format.py
docker exec -u frappe -w /home/frappe/frappe-bench erpnext-backend-1 \\
env/bin/python /tmp/setup_invoice_print_format.py
The script is idempotent: it updates the Print Format if it already exists.
"""
import os
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
# Print Settings: Letter + canonical number format
frappe.db.set_single_value("Print Settings", "pdf_page_size", "Letter")
frappe.db.set_single_value("System Settings", "number_format", "#,###.##")
frappe.db.set_single_value("System Settings", "currency_precision", "2")
frappe.db.commit()
print(" PDF page size set to Letter, number format set to #,###.##")
PRINT_FORMAT_NAME = "Facture TARGO"
# ── Jinja template ────────────────────────────────────────────────────────
# Kept in-file so the script is self-contained and version-controlled.
# Source of truth: this string. Regenerate PDFs via
# /api/method/frappe.utils.print_format.download_pdf?doctype=Sales+Invoice&name=<SINV>&format=Facture+TARGO
html_template = r"""{#- ══════════════════════════════════════════════════════════════════════════
Facture TARGO — ERPNext Print Format (Sales Invoice)
The HTML/CSS below is a verbatim copy of scripts/migration/invoice_preview.jinja
(the canonical design that renders correctly under Chromium/Antigravity).
Only the top Jinja prelude is new: it resolves `doc.*` + whitelisted helpers
into the plain context variables the preview template expects.
Depends on:
- custom app `gigafibre_utils` (QR + logo + short_item_name helpers)
- PDF generator set to `chrome` on the Print Format (Chromium in the image)
══════════════════════════════════════════════════════════════════════════ -#}
{%- macro money(v) -%}{{ "%.2f"|format((v or 0)|float) }}{%- endmacro -%}
{%- macro _clean(s) -%}{{ (s or "") | replace("&#039;","'") | replace("&amp;","&") | replace("&lt;","<") | replace("&gt;",">") | replace("&quot;",'"') }}{%- endmacro -%}
{%- set mois_fr = {"January":"janvier","February":"février","March":"mars","April":"avril","May":"mai","June":"juin","July":"juillet","August":"août","September":"septembre","October":"octobre","November":"novembre","December":"décembre"} -%}
{%- macro date_short(d) -%}
{%- if d -%}
{%- set dt = frappe.utils.getdate(d) -%}
{{ "%02d/%02d/%04d" | format(dt.day, dt.month, dt.year) }}
{%- else -%}—{%- endif -%}
{%- endmacro -%}
{%- macro date_fr(d) -%}
{%- if d -%}
{%- set dt = frappe.utils.getdate(d) -%}
{{ dt.day }} {{ mois_fr.get(dt.strftime("%B"), dt.strftime("%B")) }} {{ dt.year }}
{%- else -%}—{%- endif -%}
{%- endmacro -%}
{#- ── Preview context variables resolved from the Sales Invoice ───────────── -#}
{%- set invoice_number = doc.name -%}
{%- set invoice_date = date_short(doc.posting_date) -%}
{%- set account_number = doc.customer -%}
{%- set client_name = doc.customer_name -%}
{%- set client_address_line1 = "" -%}
{%- set client_address_line2 = "" -%}
{%- if doc.customer_address -%}
{%- set a = frappe.get_doc("Address", doc.customer_address) -%}
{%- set client_address_line1 = a.address_line1 or "" -%}
{%- set _city = (a.city or "") -%}
{%- set _state = (a.state or "") -%}
{%- set _pin = (a.pincode or "") -%}
{%- set client_address_line2 = (_city ~ (", " if _city and _state else "") ~ _state ~ (" " if _pin else "") ~ _pin) | trim -%}
{%- endif -%}
{#- ── Language detection ─────────────────────────────────────────────────────
Reads Customer.language (set on the Customer record → More Info tab).
Frappe stores values like "fr", "fr-CA", "en", "en-US".
Anything starting with "en" is treated as English; all else → French.
This controls every user-visible label: document title, meta table, totals,
legal text. The QUOTE template reuses the same block with lbl_doc_quote. -#}
{%- set _cust_lang = (frappe.db.get_value("Customer", doc.customer, "language") or "fr") | lower -%}
{%- set _is_en = _cust_lang[:2] == "en" -%}
{#- Document title — FACTURE / INVOICE or NOTE DE CRÉDIT / CREDIT NOTE -#}
{%- if doc.is_return -%}
{%- set doc_title = "CREDIT NOTE" if _is_en else "NOTE DE CR\u00c9DIT" -%}
{%- else -%}
{%- set doc_title = "INVOICE" if _is_en else "FACTURE" -%}
{%- endif -%}
{#- ── TPS / TVQ split ──────────────────────────────────────────────────────── -#}
{#- Jinja {% set %} inside a {% for %} loop does NOT mutate outer scope —
must use a namespace object. Also accumulate (not assign) in case the
Sales Invoice has multiple TPS/TVQ rows. Match on description OR account_head
so renamed Tax Templates (e.g. "GST/HST", "QST") still classify correctly. -#}
{%- set ns = namespace(tps=0, tvq=0) -%}
{%- for t in doc.taxes or [] -%}
{%- set _desc = ((t.description or "") ~ " " ~ (t.account_head or "")) | upper -%}
{%- if "TPS" in _desc or "GST" in _desc or "HST" in _desc -%}
{%- set ns.tps = ns.tps + (t.tax_amount or 0) -%}
{%- elif "TVQ" in _desc or "QST" in _desc or "PST" in _desc -%}
{%- set ns.tvq = ns.tvq + (t.tax_amount or 0) -%}
{%- endif -%}
{%- endfor -%}
{#- Fallback: if no tax row matched the name heuristics but the invoice
DOES have total taxes, split 1/3 TPS, 2/3 TVQ (Québec standard 5%/9.975%
≈ 1:2 ratio). Keeps the displayed sum equal to total_taxes_and_charges. -#}
{%- if (ns.tps + ns.tvq) == 0 and (doc.total_taxes_and_charges or 0) > 0 -%}
{%- set _tot = doc.total_taxes_and_charges -%}
{%- set ns.tps = (_tot * 5.0 / 14.975) | round(2) -%}
{%- set ns.tvq = _tot - ns.tps -%}
{%- endif -%}
{%- set tps_amount = money(ns.tps) -%}
{%- set tvq_amount = money(ns.tvq) -%}
{%- set total_taxes = money(doc.total_taxes_and_charges) -%}
{%- set subtotal_before_taxes = money(doc.net_total) -%}
{%- set current_charges_total = money(doc.grand_total) -%}
{%- set due_date = date_fr(doc.due_date) -%}
{#- ── Service period window (used both by the items loop for per-line
period labels and by any block that needs the overall period). ── -#}
{%- set svc_start = frappe.utils.getdate(doc.posting_date) -%}
{%- set svc_end = frappe.utils.add_days(frappe.utils.add_months(doc.posting_date, 1), -1) -%}
{#- ── Group items by Service Location (address label). Consolidate duplicates. -#}
{%- set _groups = {} -%}
{%- set _order = [] -%}
{%- for it in doc.items -%}
{%- set loc_key = it.get("service_location") or "_default" -%}
{%- if loc_key not in _groups -%}
{%- set _ = _order.append(loc_key) -%}
{%- set _label = "" -%}
{%- if loc_key == "_default" -%}
{%- set _label = client_address_line1 or "Tous les services" -%}
{%- else -%}
{%- set _ld = frappe.get_cached_doc("Service Location", loc_key) -%}
{%- set _street = _ld.address_line or "" -%}
{%- set _city = _ld.city or "" -%}
{%- set _label = _street -%}
{%- if _city and _street -%}{%- set _label = _label ~ ", " ~ _city -%}
{%- elif _city -%}{%- set _label = _city -%}{%- endif -%}
{%- set _label = _label or _ld.location_name or loc_key -%}
{%- endif -%}
{%- set _ = _groups.update({loc_key: {"name": _label, "items": [], "index": {}, "subtotal_val": 0}}) -%}
{%- endif -%}
{%- set g = _groups[loc_key] -%}
{%- set _ikey = _clean(it.item_name or it.description) ~ "||" ~ ("%.4f" | format((it.rate or 0)|float)) -%}
{%- set _eidx = g["index"].get(_ikey) -%}
{%- if _eidx is not none -%}
{%- set _r = g["items"][_eidx] -%}
{%- set _ = _r.update({"qty": (_r.qty or 0) + (it.qty or 0), "amount_val": (_r.amount_val or 0) + (it.amount or 0), "amount": money((_r.amount_val or 0) + (it.amount or 0))}) -%}
{%- else -%}
{%- set _short = frappe.call("gigafibre_utils.api.short_item_name", name=(it.item_name or it.description), max_len=48) -%}
{%- set _desc_base = _short or _clean(it.item_name or it.description) -%}
{#- Per-item service period. Only set when the item CARRIES EXPLICIT
service_start_date + service_end_date (ERPNext deferred-revenue items).
No more defaulting to the current billing window — most items don't
need to repeat "16 avril au 15 mai" on every line. -#}
{%- set _period_str = "" -%}
{%- if it.get("service_start_date") and it.get("service_end_date") -%}
{%- set _ps = frappe.utils.getdate(it.service_start_date) -%}
{%- set _pe = frappe.utils.getdate(it.service_end_date) -%}
{%- set _psm = mois_fr.get(_ps.strftime("%B"), "") -%}
{%- set _pem = mois_fr.get(_pe.strftime("%B"), "") -%}
{%- if _ps.month == _pe.month -%}
{%- set _period_str = (("1er " if _ps.day == 1 else (_ps.day|string ~ " "))) ~ _psm ~ " au " ~ _pe.day|string ~ " " ~ _pem -%}
{%- else -%}
{%- set _period_str = _ps.day|string ~ " " ~ _psm ~ " au " ~ _pe.day|string ~ " " ~ _pem -%}
{%- endif -%}
{%- endif -%}
{#- Period is stored SEPARATELY (not appended to description) so the
template can render it once per location block instead of once per
item — much less noise when 5 items share the same billing window. -#}
{%- set _ = g["items"].append({
"description": _desc_base,
"period_str": _period_str,
"qty": (it.qty | int if it.qty == it.qty|int else it.qty),
"unit_price": money(it.rate),
"amount": money(it.amount),
"amount_val": (it.amount or 0),
"is_credit": (it.amount or 0) < 0,
}) -%}
{%- set _ = g["index"].update({_ikey: g["items"]|length - 1}) -%}
{%- endif -%}
{%- set _ = g.update({"subtotal_val": g["subtotal_val"] + (it.amount or 0)}) -%}
{%- endfor -%}
{%- set current_charges_locations = [] -%}
{%- for k in _order -%}
{%- set g = _groups[k] -%}
{%- set _ = current_charges_locations.append({"name": g["name"], "items": g["items"], "subtotal": money(g["subtotal_val"])}) -%}
{%- endfor -%}
{#- ── Previous invoice + recent payments ──────────────────────────────────── -#}
{%- set _prev_list = frappe.get_all("Sales Invoice",
filters={"customer": doc.customer, "docstatus": 1, "name": ["!=", doc.name], "posting_date": ["<", doc.posting_date]},
fields=["name","posting_date","grand_total","outstanding_amount"],
order_by="posting_date desc", limit_page_length=1) or [] -%}
{%- set _prev = _prev_list[0] if _prev_list else None -%}
{%- set prev_invoice_date = date_short(_prev.posting_date) if _prev else "" -%}
{%- set prev_invoice_total = money(_prev.grand_total) if _prev else "0.00" -%}
{%- set remaining_balance = money(_prev.outstanding_amount) if _prev else "0.00" -%}
{%- set recent_payments = [] -%}
{%- if _prev -%}
{%- set _refs = frappe.get_all("Payment Entry Reference",
filters={"reference_doctype":"Sales Invoice","reference_name": _prev.name},
fields=["allocated_amount"]) or [] -%}
{%- for r in _refs -%}
{%- set _ = recent_payments.append({"amount": money(r.allocated_amount)}) -%}
{%- endfor -%}
{%- endif -%}
{%- set total_amount_due = money((doc.outstanding_amount or 0) + (_prev.outstanding_amount if _prev else 0)) -%}
{#- ── QR code (base64 data URI generated server-side) ─────────────────────── -#}
{%- set qr_code_base64 = frappe.call("gigafibre_utils.api.invoice_qr_base64", invoice=doc.name) -%}
{#- ── Referral code (stable 6 chars per Customer) ──────────────────────────── -#}
{%- set referral_code = frappe.call("gigafibre_utils.api.referral_code", customer=doc.customer) -%}
{#- (service-period variables moved earlier in the prelude, before the
items loop needs them) -#}
{#- ═════════════════════════════════════════════════════════════════════════
ORIGINAL PREVIEW TEMPLATE (invoice_preview.jinja), unmodified below.
═════════════════════════════════════════════════════════════════════════ -#}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Facture {{ invoice_number }}</title>
<style>
/* @page: size + margins. Chromium honors `counter(page)` / `counter(pages)`
inside @top-right to inject page numbers on multi-page overflow.
We suppress the counter on page 1 via `@page:first` — CSS can't test
"total pages == 1", but it CAN hide on the first page. So:
- Single-page invoice (99% case): no "Page 1 de 1" clutter.
- Multi-page invoice: page 2+ shows "Page 2 de 3", "Page 3 de 3", etc.
(page 1 stays clean, which is acceptable — the header clearly marks
the document's first page). */
@page {
size: Letter;
margin: 10mm 15mm 8mm 20mm;
@top-right {
content: "Page " counter(page) " de " counter(pages);
font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 7pt;
color: #888;
padding-top: 5mm;
}
}
@page:first {
@top-right { content: ""; }
}
* { box-sizing: border-box; }
/* ⚠️ Kill Frappe's aggressive `!important` overrides:
1. `.print-format { padding: 0.75in }` shoves content ~19mm down.
2. `.print-format td img { width: 100% !important }` stretches inline
QR image to the full td width, breaking its square aspect.
3. `.print-format td { padding: 10px !important }` bloats row heights
AND eats ~20mm of horizontal space from our 2-column main layout
(10px each side × 2 cols × 2 edges ≈ 40px ≈ 10mm per column).
Fix: blanket-reset ALL td/th padding to 0 with !important, then let
each specific table add back exactly the padding it needs (again
with !important). Same specificity (0,1,1) vs Frappe's (0,1,1),
ours is declared later in the cascade → wins. */
.print-format { padding: 0 !important; margin: 0 !important; max-width: none !important; }
.print-format img, .print-format td img { width: auto !important; max-width: 100% !important; height: auto !important; }
.print-format img.qr-img, .print-format td img.qr-img { width: 40px !important; height: 40px !important; max-width: 40px !important; }
/* Blanket neutralisation of Frappe's default td/th padding. Every table
below re-adds its own padding in its own rule. */
.print-format td, .print-format th { padding: 0 !important; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 8pt; color: #333; line-height: 1.2; margin: 0; }
@media screen {
body { padding: 40px; max-width: 8.5in; margin: 0 auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); background: #fff; }
html { background: #f0f0f0; }
}
/* ── Pre-envelope-window summary (previous balance + payments) ──
Sits between the logo and the address. Hidden by the envelope when
folded; becomes the first thing the customer reads after opening.
Collapsed to minimum height: 3 rows + title ≈ 11-12mm so the address
still lands at the ~2" envelope-window top. */
.pre-window { margin-top: 4mm; }
.pw-title { font-size: 6.5pt; font-weight: 700; color: #019547; border-bottom: 1px solid #019547; padding-bottom: 1px; margin-bottom: 1px; letter-spacing: 0.3px; line-height: 1.1; }
.pw-table { width: 100%; font-size: 7pt; line-height: 1.25; border-collapse: collapse; }
.pw-table td { padding: 1px 2px !important; vertical-align: baseline; line-height: 1.25; }
.pw-table td.r { text-align: right; white-space: nowrap; }
.pw-table tr.pmt td { color: #019547; }
.pw-table tr.bal td { font-style: italic; color: #666; border-bottom: 1px solid #eee; }
/* ── Metadata Elements ── */
.doc-label { font-size: 14pt; font-weight: 700; color: #019547; }
.doc-page { font-size: 7pt; color: #888; }
/* ── Meta band (under logo) ── */
.meta-band { width: 100%; padding: 6px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; margin: 15px 0 0 0; }
.meta-band td { vertical-align: middle; padding: 0 6px; text-align: center; }
.meta-band .ml { color: #888; display: block; font-size: 5.5pt; text-transform: uppercase; margin-bottom: 2px; }
.meta-band .mv { font-weight: 700; color: #333; font-size: 7pt; }
/* ── Main 2-column layout ──
Letter content width (after @page margins 20mm L + 15mm R) = 180.9mm.
Items list is the MAIN focus — left column dominates at 66%. Right
column is 1/3 of usable width (= 60.3mm ≈ 2.37"). r-meta (account/
date/invoice#) is stacked vertically as label|value rows since 3
side-by-side cells no longer fit in 60mm.
col-l = 66% → 119.4mm (4.70")
spacer = 1% → 1.8mm (0.07")
col-r = 33% → 59.7mm (2.35" = 1/3 usable width)
`table-layout: fixed` forces the browser to honour declared widths
instead of auto-sizing to content. */
.main { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.main td { vertical-align: top; }
.col-l { width: 66%; }
.col-spacer { width: 1%; }
.col-r { width: 33%; }
/* ── Envelope-window block — address only.
Left column has logo (~7mm) + pre-window SOMMAIRE (~11-12mm, 3 rows:
solde précédent / paiement reçu / solde reporté). margin-top is set
inline on the element (30mm with prev, 33mm without — both target the
~2" / 51mm envelope-window top counting from page-margin origin);
margin-bottom 10mm clears FRAIS COURANTS past the window bottom. */
.cl-block { margin-bottom: 6mm; padding-left: 4px; }
.cl-lbl { font-size: 7pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 1px; }
.cl-name { font-size: 11pt; font-weight: 700; color: #222; line-height: 1.15; }
.cl-addr { font-size: 10pt; color: #333; line-height: 1.2; }
/* ── Left: summary ── */
.summary-hdr { font-size: 8pt; font-weight: 700; color: #111;
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
padding: 3px 6px; margin-bottom: 2px; line-height: 1; }
.section-hdr { font-weight: 600; font-size: 7.5pt; padding-top: 3px; margin-bottom: 1px; }
.addr-hdr { font-weight: 600; font-size: 6.5pt; color: #111;
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
padding: 2px 6px; margin-top: 3px; line-height: 1.2; }
.addr-hdr .addr-period { color: #555; font-weight: 400; font-size: 6pt; margin-left: 3px; }
/* table-layout: auto so colspan="2" sep rows span cleanly without
Chromium forcing a phantom column boundary at the 86% mark. */
.dtl { width: 100%; border-collapse: collapse; font-size: 7.5pt; margin-bottom: 2px; line-height: 1.35; }
.dtl th.r, .dtl td.r { text-align: right; white-space: nowrap; }
/* Item rows — white background; green is reserved for section/address headers. */
.dtl td { padding: 3px 6px 1px !important; vertical-align: baseline; line-height: 1.35; }
/* Separator row: block-level <div class="sep-line"> draws the dotted line —
immune to border-collapse column-splitting, one continuous vector line. */
.dtl tr.sep td { height: 3px; padding: 0 !important; line-height: 0; font-size: 0; border: none !important; }
.sep-line { border-top: 1px solid #e5e7eb; height: 0; margin: 0; padding: 0; }
/* Description column wraps instead of ellipsis-truncating. */
.dtl td:first-child { white-space: normal; word-break: break-word; }
.qty-inline { color: #888; font-size: 7pt; }
.dtl tr.credit td { color: #019547; }
/* Totals block: gray background for subtotal + grand total rows. */
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #d1d5db; padding-top: 3px !important;
background: #f3f4f6 !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.dtl tr.tax td { color: #666; font-size: 6.5pt; padding: 1px 6px !important; background: #fff !important; }
.dtl tr.grand td { font-weight: 700; font-size: 8pt; border-top: 1px solid #019547; padding-top: 4px !important;
background: #e5e7eb !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
/* Keep totals block together so an orphan row never lands alone on page 2. */
.dtl.totals { page-break-inside: avoid; break-inside: avoid; }
.dtl tr.taxnums-row td { font-size: 6pt; color: #999; padding: 2px 6px 3px !important; text-align: left; background: #fff !important; }
/* ── Right column: all content in a bordered container ── */
.r-container { border: 1px solid #e5e7eb; border-left: 2px solid #019547; }
.r-section { padding: 6px 8px; }
.r-section.r-meta { padding: 4px 8px; background: #f8fafc; }
/* r-meta: label | value rows (stacked) — fits in the narrow 45mm col-r
where the old 3-column side-by-side layout couldn't. Label left,
value right, each pair on its own row. */
.r-meta-table { width: 100%; border-collapse: collapse; }
.r-meta-table td { vertical-align: baseline; line-height: 1.25; padding: 1px 0; font-size: 7pt; }
.r-meta-table td.ml { color: #888; font-size: 5.5pt; text-transform: uppercase; letter-spacing: 0.3px; text-align: left; }
.r-meta-table td.mv { font-weight: 700; color: #333; white-space: nowrap; text-align: right; }
.r-section + .r-section { border-top: 1px solid #e5e7eb; }
.r-section.r-green { background: #e8f5ee; }
.r-section.r-gray { background: #f8f9fa; }
/* Amount due */
.ab-label { font-size: 8.5pt; font-weight: 600; color: #019547; line-height: 1.1; }
.ab-val { font-size: 18pt; font-weight: 700; color: #222; text-align: right; white-space: nowrap; line-height: 1.05; }
.ab-date { font-size: 7pt; color: #555; margin-top: 1px; }
.ab-table { width: 100%; border-collapse: collapse; }
.ab-table td { padding: 0; border: none; vertical-align: middle; }
/* QR */
.qr-table { width: 100%; border-collapse: collapse; }
.qr-table td { padding: 1px 6px; border: none; vertical-align: middle; font-size: 7.5pt; line-height: 1.2; }
.qr-placeholder { width: 40px; height: 40px; background: #ccc; font-size: 5pt; color: #888; text-align: center; line-height: 40px; }
/* Info block */
.r-info-text { font-size: 6.5pt; color: #555; line-height: 1.25; }
.r-info-text strong { color: #333; }
.r-info-text .ri-title { font-weight: 700; font-size: 7pt; color: #333; margin-bottom: 2px; }
.r-section.r-cprst { font-size: 5.5pt; padding: 3px 10px; color: #888; background: #fafbfc; border-top: 1px solid #e5e7eb; line-height: 1.2; }
/* Referral program — light gray background, green accent on the code */
.r-section.r-referral { background: #f8fafc; font-size: 7pt; color: #333; line-height: 1.2; }
.r-section.r-referral .ri-title { font-weight: 700; font-size: 7.5pt; color: #019547; margin-bottom: 2px; }
.ref-code-box { display: flex; align-items: center; gap: 8px; background: #fff; border: 1.5px solid #019547; border-radius: 4px; padding: 3px 8px; margin-top: 3px; box-shadow: 0 0 0 2px #e8f5ee; }
.ref-code-label { font-size: 6pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; }
.ref-code { font-size: 13pt; font-weight: 700; color: #019547; letter-spacing: 2px; font-family: 'SF Mono', 'Menlo', 'Courier New', monospace; margin-left: auto; line-height: 1.1; }
.r-section.r-referral small { display: block; font-size: 5.5pt; color: #019547; margin-top: 3px; font-weight: 600; }
/* ── Footer ── */
.footer-line { font-size: 6pt; color: #bbb; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<!-- ═══════ HEADER & MAIN 2 COLUMNS ═══════ -->
<div>
<table class="main"><tr>
<!-- ── LEFT 58% (~105mm = 4.13") ── -->
<td class="col-l">
<!-- ── FIXED-HEIGHT HEADER ZONE ──
Logo + optional SOMMAIRE DU COMPTE live here. Height is HARDCODED
regardless of SOMMAIRE presence, so the address block below ALWAYS
lands at the same Y. Robust to variations (no prev invoice / etc.).
30mm + @page margin 10mm + cl-block margin 3mm + FACTURER label
= "Les..." at ~153pt (2.13") — matches v4 reference pixel-perfect. -->
<div class="top-zone" style="height: 30mm; overflow: hidden;">
<div style="height: 26px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" style="height: 26px; width: auto;"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
</div>
<!-- Pre-envelope-window zone — only rendered if there is a previous
invoice. Hidden by the envelope when folded; first thing the
customer reads once opened. -->
{% if prev_invoice_date and prev_invoice_date != '' %}
<div class="pre-window">
<div class="pw-title">{% if _is_en %}ACCOUNT SUMMARY{% else %}SOMMAIRE DU COMPTE{% endif %}</div>
<table class="pw-table">
<tr>
<td>{% if _is_en %}Previous balance{% else %}Solde précédent{% endif %} ({{ prev_invoice_date }})</td>
<td class="r">$ {{ prev_invoice_total }}</td>
</tr>
{% for payment in recent_payments %}
<tr class="pmt"><td>{% if _is_en %}Payment received — thank you{% else %}Paiement reçu — merci{% endif %}</td><td class="r"> $ {{ payment.amount }}</td></tr>
{% endfor %}
<tr class="bal"><td>{% if _is_en %}Carried forward{% else %}Solde reporté{% endif %}</td><td class="r">$ {{ remaining_balance }}</td></tr>
</table>
</div>
{% endif %}
</div><!-- /top-zone -->
<!-- Address block — ALWAYS at same Y thanks to the fixed-height
top-zone above. With @page margin-top=10mm + top-zone 38mm + this
margin-top 3mm → "Les..." renders at ~51mm = 2" from page top
edge (envelope window opening). -->
<div class="cl-block" style="margin-top: 3mm;">
<div class="cl-lbl">{% if _is_en %}Bill to{% else %}Facturer &agrave;{% endif %}</div>
<div class="cl-name">{{ client_name }}</div>
<div class="cl-addr">
{{ client_address_line1 }}<br>
{{ client_address_line2 }}
</div>
</div>
<!-- Current charges -->
<div class="summary-hdr">{% if _is_en %}CURRENT CHARGES{% else %}FRAIS COURANTS{% endif %}</div>
{% for location in current_charges_locations %}
{#- Collect distinct non-empty periods from this location's items. If
they're all the same, show once in the address header. If they
differ (rare), append to each item's description as before. -#}
{%- set _periods = [] -%}
{%- for _it in location['items'] -%}
{%- if _it.period_str and _it.period_str not in _periods -%}
{%- set _ = _periods.append(_it.period_str) -%}
{%- endif -%}
{%- endfor -%}
<div class="addr-hdr">&#9678; {{ location.name }}{% if _periods|length == 1 %} <span class="addr-period"{{ _periods[0] }}</span>{% endif %}</div>
<table class="dtl">
<colgroup>
<col style="width:86%">
<col style="width:14%">
</colgroup>
<tbody>
{% for item in location['items'] %}
<tr class="{% if item.is_credit %}credit{% endif %}">
<td>{{ item.description }}{% if _periods|length > 1 and item.period_str %} <span class="qty-inline">({{ item.period_str }})</span>{% endif %}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %}</td>
<td class="r">{{ item.amount }}</td>
</tr>
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
<table class="dtl totals" style="margin-top:3px;">
<colgroup><col style="width:82%"><col style="width:18%"></colgroup>
<tr class="stot"><td>{% if _is_en %}Subtotal before tax{% else %}Sous-total avant taxes{% endif %}</td><td class="r">$ {{ subtotal_before_taxes }}</td></tr>
<tr class="tax"><td>{% if _is_en %}Tax: GST ${{ tps_amount }} · QST ${{ tvq_amount }}{% else %}Taxes : TPS ${{ tps_amount }} · TVQ ${{ tvq_amount }}{% endif %}</td><td class="r">$ {{ total_taxes }}</td></tr>
<tr class="grand"><td>TOTAL</td><td class="r">$ {{ current_charges_total }}</td></tr>
<tr class="taxnums-row"><td colspan="2">TPS: 834975559RT0001 &nbsp;|&nbsp; TVQ: 1213765929TQ0001</td></tr>
</table>
</td>
<!-- ── SPACER: 2% of content width ≈ 3.6mm ≈ 1/7 inch.
Padding on this td is zeroed by the blanket `.print-format td { padding: 0 }`
rule so the declared 2% width actually delivers the visible gap. -->
<td class="col-spacer"></td>
<!-- ── RIGHT 40%: all in one bordered container ── -->
<td class="col-r">
<!-- Document title: FACTURE / INVOICE / NOTE DE CRÉDIT / CREDIT NOTE
driven by _is_en + doc.is_return set in the Jinja prelude above. -->
<div style="text-align: right; margin-bottom: 3px;">
<div class="doc-label" style="line-height:1;">{{ doc_title }}</div>
</div>
<div class="r-container">
<!-- Facture identifiers: stacked label|value rows so a long account
number ("C-041452257623184") doesn't force col-r wider than the
60mm target. Values right-aligned so the digits line up visually. -->
<div class="r-section r-meta">
<table class="r-meta-table">
<tr><td class="ml">{% if _is_en %}Invoice #{% else %}N&ordm; facture{% endif %}</td><td class="mv">{{ invoice_number }}</td></tr>
<tr><td class="ml">Date</td><td class="mv">{{ invoice_date }}</td></tr>
<tr><td class="ml">{% if _is_en %}Account #{% else %}N&ordm; compte{% endif %}</td><td class="mv">{{ account_number }}</td></tr>
</table>
</div>
<!-- (Sommaire du compte moved to the LEFT pre-window zone —
right column stays action-focused: amount + QR + contact.) -->
<!-- Amount due -->
<div class="r-section r-green">
<table class="ab-table"><tr>
<td><div class="ab-label">{% if _is_en %}Amount due{% else %}Montant d&ucirc;{% endif %}</div><div class="ab-date">{% if _is_en %}before{% else %}avant le{% endif %} {{ due_date }}</div></td>
<td class="ab-val">$ {{ total_amount_due }}</td>
</tr></table>
</div>
<!-- QR -->
<div class="r-section r-green">
<table class="qr-table"><tr>
<td style="width:46px;">
{% if qr_code_base64 %}
<img class="qr-img" src="data:image/png;base64,{{ qr_code_base64 }}" style="width:40px !important; height:40px !important; max-width:40px !important;" />
{% else %}
<div class="qr-placeholder">QR</div>
{% endif %}
</td>
<td><strong>{% if _is_en %}Pay online{% else %}Payez en ligne{% endif %}</strong><br>{% if _is_en %}Scan the QR code or visit{% else %}Scannez le code QR ou visitez{% endif %}<br><strong style="color:#019547">client.gigafibre.ca</strong></td>
</tr></table>
</div>
<!-- Referral program — URL now lives INSIDE the descriptive sentence
(as a call-to-action), not below the code box where it had no
visual connection to the rest of the content. -->
<div class="r-section r-referral">
{% if _is_en %}
Refer a friend and each receive <strong>$50 credit</strong> on your monthly bill &middot; <a href="https://targo.ca/parrainage" style="color:#019547; text-decoration:none; font-weight:600;">targo.ca/parrainage</a>
{% else %}
R&eacute;f&eacute;rez un ami et recevez <strong>chacun 50&nbsp;$ de cr&eacute;dit</strong> sur votre facture mensuelle &middot; <a href="https://targo.ca/parrainage" style="color:#019547; text-decoration:none; font-weight:600;">targo.ca/parrainage</a>
{% endif %}
<div class="ref-code-box">
<span class="ref-code-label">{% if _is_en %}Your code{% else %}Votre code{% endif %}</span>
<span class="ref-code">{{ referral_code }}</span>
</div>
</div>
<!-- Notices & conditions -->
<div class="r-section r-info-text">
{% if _is_en %}
By paying this invoice, you accept our <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">terms and conditions</a>.<br><br>
Please note that any invoice not paid by the due date will be subject to a late payment fee.
{% else %}
En payant cette facture, vous acceptez nos <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">termes et conditions</a>.<br><br>
Prendre note que toute facture non acquitt&eacute;e &agrave; la date d'&eacute;ch&eacute;ance sera assujettie &agrave; des frais de retard.
{% endif %}
</div>
<!-- Contact info + TARGO coordinates -->
<div class="r-section r-gray r-info-text">
<div class="ri-title">{% if _is_en %}Contact us{% else %}Contactez-nous{% endif %}</div>
<strong>TARGO Communications Inc.</strong><br>
1867 chemin de la Rivi&egrave;re<br>
Sainte-Clotilde, QC J0L 1W0<br>
<strong>855 888-2746</strong> &middot; {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
info@targo.ca &bull; www.targo.ca
</div>
<!-- CPRST notice (required by law, kept compact) -->
<div class="r-section r-info-text r-cprst">
{% if _is_en %}
Complaint about your telecom or TV service? The CCTS can help you for free: <strong>www.ccts-cprst.ca</strong>.
{% else %}
Plainte relative &agrave; votre service de t&eacute;l&eacute;communication ou de t&eacute;l&eacute;vision? La CPRST peut vous aider sans frais&nbsp;: <strong>www.ccts-cprst.ca</strong>.
{% endif %}
</div>
</div>
</td>
</tr></table>
</div>
<!-- (Company address moved to the Contactez-nous section in the right column) -->
</body>
</html>
"""
# Print Format options shared between create/update paths. pdf_generator="chrome"
# uses Chromium headless for pixel-accurate modern-CSS rendering (matches the
# Antigravity / browser preview). Chromium must be installed in the container;
# see /opt/erpnext/custom/Dockerfile (apt-get install chromium) and
# common_site_config.json → "chromium_path": "/usr/bin/chromium".
#
# Margins are explicit — Chrome PDF generator IGNORES `@page { margin: … }`
# in the CSS (preferCSSPageSize=False). We keep 5mm top/bottom so the client
# address stays aligned with the #10 envelope window (~2" from top edge).
_PF_OPTIONS = {
"print_format_type": "Jinja",
"standard": "No",
"custom_format": 1,
"pdf_generator": "chrome",
"margin_top": 5,
"margin_bottom": 5,
"margin_left": 15,
"margin_right": 15,
"disabled": 0,
}
# ── Create or update the Print Format ──
if frappe.db.exists("Print Format", PRINT_FORMAT_NAME):
pf = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
pf.html = html_template
for k, v in _PF_OPTIONS.items():
setattr(pf, k, v)
pf.save(ignore_permissions=True)
print(f" Updated Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
else:
pf = frappe.get_doc({
"doctype": "Print Format",
"name": PRINT_FORMAT_NAME,
"__newname": PRINT_FORMAT_NAME,
"doc_type": "Sales Invoice",
"module": "Accounts",
**_PF_OPTIONS,
"html": html_template,
"default_print_language": "fr",
})
pf.insert(ignore_permissions=True)
print(f" Created Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
# ── Set as default print format for Sales Invoice ──
try:
frappe.db.set_value(
"Property Setter", None, "value", PRINT_FORMAT_NAME,
{
"doctype_or_field": "DocType",
"doc_type": "Sales Invoice",
"property": "default_print_format",
},
)
except Exception as e:
print(f" (info) could not set default print format: {e}")
# ── Disable the legacy Server Script "invoice_qr_code" if still around ──
# QR generation now lives in the custom app `gigafibre_utils` (whitelisted
# method `gigafibre_utils.api.invoice_qr_base64`). The old Server Script
# crashed under safe_exec because it tried `import qrcode`.
if frappe.db.exists("Server Script", "invoice_qr_code"):
ss = frappe.get_doc("Server Script", "invoice_qr_code")
if not ss.disabled:
ss.disabled = 1
ss.save(ignore_permissions=True)
print(" Disabled legacy Server Script: invoice_qr_code "
"(superseded by gigafibre_utils.api.invoice_qr_base64)")
frappe.db.commit()
print(f"\n Done. Print Format '{PRINT_FORMAT_NAME}' ready.")
print(" Dependency: custom app `gigafibre_utils` must be installed "
"(bench --site erp.gigafibre.ca install-app gigafibre_utils).")
print(" Preview: ERPNext → Sales Invoice → Print → Select 'Facture TARGO'")