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>
700 lines
39 KiB
Python
700 lines
39 KiB
Python
"""
|
||
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("'","'") | replace("&","&") | replace("<","<") | replace(">",">") | replace(""",'"') }}{%- 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 à{% 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">◎ {{ 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 | 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º 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º 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û{% 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 · <a href="https://targo.ca/parrainage" style="color:#019547; text-decoration:none; font-weight:600;">targo.ca/parrainage</a>
|
||
{% else %}
|
||
Référez un ami et recevez <strong>chacun 50 $ de crédit</strong> sur votre facture mensuelle · <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ée à la date d'échéance sera assujettie à 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ère<br>
|
||
Sainte-Clotilde, QC J0L 1W0<br>
|
||
<strong>855 888-2746</strong> · {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
|
||
info@targo.ca • 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 à votre service de télécommunication ou de télévision? La CPRST peut vous aider sans frais : <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'")
|