gigafibre-fsm/scripts/migration/setup_quote_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

481 lines
24 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 "Soumission TARGO" on ERPNext.
Design
------
- Same CSS as "Facture TARGO" (invoice) — consistent branding.
- Doctype: Quotation.
- Right column: total estimé + QR code → DocuSeal signing link + "Signer" CTA.
No payment section, no SOMMAIRE DU COMPTE, no CPRST notice.
- Language-aware: SOUMISSION (fr) / QUOTE (en) driven by Customer.language.
- Items grouped by Quotation Item.service_location (custom field, optional —
falls back gracefully if the field is absent on the child table).
- QR code links to DocuSeal signing URL stored in custom field
`custom_docuseal_signing_url` on the Quotation. If blank, a placeholder
is shown (URL will be populated by n8n after envelope creation).
Custom fields required on Quotation (create via ERPNext Customize Form):
- custom_docuseal_signing_url (Data, read-only) — populated by n8n
- custom_quote_type (Select: residential/commercial)
- custom_docuseal_envelope_id (Data, read-only)
Run (inside erpnext-backend-1)
------------------------------
docker cp scripts/migration/setup_quote_print_format.py \\
erpnext-backend-1:/tmp/setup_quote_print_format.py
docker exec erpnext-backend-1 \\
/home/frappe/frappe-bench/env/bin/python /tmp/setup_quote_print_format.py
"""
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_FORMAT_NAME = "Soumission TARGO"
html_template = r"""{#- ══════════════════════════════════════════════════════════════════════════
Soumission TARGO — ERPNext Print Format (Quotation)
Same CSS as Facture TARGO; right column replaced with DocuSeal signing CTA.
Depends on:
- custom app `gigafibre_utils` (QR + logo + short_item_name helpers)
- PDF generator set to `chrome`
- Custom fields on Quotation: custom_docuseal_signing_url, custom_quote_type
══════════════════════════════════════════════════════════════════════════ -#}
{%- 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 -%}
{#- ── Core variables ── -#}
{%- set quote_number = doc.name -%}
{%- set quote_date = date_short(doc.transaction_date) -%}
{%- set customer_id = (doc.party_name if doc.quotation_to == "Customer" else "") or "" -%}
{%- set account_number = customer_id -%}
{%- set client_name = doc.customer_name -%}
{%- set valid_till = date_fr(doc.valid_till) if doc.valid_till else "" -%}
{%- set signing_url = doc.get("custom_docuseal_signing_url") or "" -%}
{#- ── Client address ── -#}
{%- 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 ── -#}
{%- set _cust_lang = ((frappe.db.get_value("Customer", customer_id, "language") if customer_id else None) or "fr") | lower -%}
{%- set _is_en = _cust_lang[:2] == "en" -%}
{%- set doc_title = "QUOTE" if _is_en else "SOUMISSION" -%}
{#- ── TPS / TVQ split ── -#}
{%- 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 -%}
{%- 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 grand_total = money(doc.grand_total) -%}
{#- ── Split items by billing frequency (Monthly / Annual / One-time) ── -#}
{%- set _monthly = [] -%}
{%- set _annual = [] -%}
{%- set _onetime = [] -%}
{%- set _sums = namespace(monthly=0, annual=0, onetime=0) -%}
{%- for it in doc.items -%}
{%- set _freq = (it.get("custom_billing_frequency") or "One-time") -%}
{%- set _short = frappe.call("gigafibre_utils.api.short_item_name", name=(it.item_name or it.description), max_len=56) -%}
{%- set _desc = _short or _clean(it.item_name or it.description) -%}
{%- set _row = {
"description": _desc,
"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),
"frequency": _freq,
} -%}
{%- if _freq == "Monthly" -%}
{%- set _ = _monthly.append(_row) -%}
{%- set _sums.monthly = _sums.monthly + (it.amount or 0) -%}
{%- elif _freq == "Annual" -%}
{%- set _ = _annual.append(_row) -%}
{%- set _sums.annual = _sums.annual + (it.amount or 0) -%}
{%- else -%}
{%- set _ = _onetime.append(_row) -%}
{%- set _sums.onetime = _sums.onetime + (it.amount or 0) -%}
{%- endif -%}
{%- endfor -%}
{%- set monthly_equiv_val = _sums.monthly + (_sums.annual / 12.0) -%}
{%- set has_recurring = (_monthly|length + _annual|length) > 0 -%}
{%- set has_onetime = _onetime|length > 0 -%}
{#- ── QR code — DocuSeal signing URL if set, else portal URL ── -#}
{%- if signing_url -%}
{%- set qr_code_base64 = frappe.call("gigafibre_utils.api.url_qr_base64", url=signing_url) -%}
{%- else -%}
{%- set qr_code_base64 = "" -%}
{%- endif -%}
{#- ── Logo ── -#}
{%- set logo_base64 = frappe.call("gigafibre_utils.api.logo_base64") -%}
<!DOCTYPE html>
<html lang="{{ 'en' if _is_en else 'fr' }}">
<head>
<meta charset="UTF-8">
<title>{{ doc_title }} {{ quote_number }}</title>
<style>
@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; }
.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; }
.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; }
}
/* ── 2-column layout ── */
.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%; }
/* ── Address block ── */
.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; }
/* ── Items section ── */
.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; }
.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; }
.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; }
.dtl td { padding: 3px 6px 1px !important; vertical-align: baseline; line-height: 1.35; }
.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; }
.dtl td:first-child { white-space: normal; word-break: break-word; }
.qty-inline { color: #888; font-size: 7pt; }
.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; }
.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 ── */
.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-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; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.r-section.r-gray { background: #f8f9fa; }
.doc-label { font-size: 14pt; font-weight: 700; color: #019547; }
/* Total estimé block */
.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: #ddd; font-size: 5pt; color: #888; text-align: center; line-height: 40px; border: 1px dashed #bbb; }
/* Sign CTA button */
.sign-btn-wrap { padding: 8px; text-align: center; }
.sign-btn { display: block; background: #019547; color: #fff !important; font-weight: 700;
font-size: 7.5pt; text-decoration: none; padding: 7px 10px; border-radius: 3px;
text-align: center; letter-spacing: 0.4px; line-height: 1.2;
print-color-adjust: exact; -webkit-print-color-adjust: exact; }
/* Info text */
.r-info-text { font-size: 6.5pt; color: #555; line-height: 1.25; }
.r-info-text strong { color: #333; }
.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; }
</style>
</head>
<body>
<div>
<table class="main"><tr>
<!-- ══ LEFT COLUMN ══ -->
<td class="col-l">
<!-- Logo (inline SVG — same asset as Facture TARGO) -->
<div style="height: 26px; margin-bottom: 4mm;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" width="121" height="26" style="display:block;"><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>
<!-- Address block -->
<div class="cl-block" style="margin-top: 8mm;">
<div class="cl-lbl">{% if _is_en %}Bill to{% else %}Soumission pour{% endif %}</div>
<div class="cl-name">{{ client_name }}</div>
<div class="cl-addr">
{{ client_address_line1 }}<br>
{{ client_address_line2 }}
</div>
</div>
<!-- Items -->
{#- ── Recurring services ── -#}
{% if has_recurring %}
<div class="summary-hdr">{% if _is_en %}RECURRING SERVICES{% else %}SERVICES RÉCURRENTS{% endif %}</div>
<table class="dtl">
<colgroup>
<col style="width:82%">
<col style="width:18%">
</colgroup>
<tbody>
{% for item in _monthly %}
<tr>
<td>{{ item.description }}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %} <span class="qty-inline">({% if _is_en %}/mo{% else %}/mois{% endif %})</span></td>
<td class="r">$ {{ item.amount }}</td>
</tr>
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
{% endfor %}
{% for item in _annual %}
<tr>
<td>{{ item.description }}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %} <span class="qty-inline">({% if _is_en %}/yr{% else %}/an{% endif %})</span></td>
<td class="r">$ {{ item.amount }}</td>
</tr>
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
{% endfor %}
<tr class="stot">
<td>{% if _is_en %}Recurring subtotal{% else %}Sous-total récurrent{% endif %}{% if _annual|length %} <span class="qty-inline">({% if _is_en %}monthly equiv.{% else %}équiv. mensuel{% endif %})</span>{% endif %}</td>
<td class="r">$ {{ money(monthly_equiv_val) }}</td>
</tr>
</tbody>
</table>
{% endif %}
{#- ── One-time fees ── -#}
{% if has_onetime %}
<div class="summary-hdr" style="margin-top:4mm;">{% if _is_en %}ONE-TIME FEES{% else %}FRAIS UNIQUES{% endif %}</div>
<table class="dtl">
<colgroup>
<col style="width:86%">
<col style="width:14%">
</colgroup>
<tbody>
{% for item in _onetime %}
<tr>
<td>{{ item.description }}{% 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 %}
<tr class="stot">
<td>{% if _is_en %}One-time subtotal{% else %}Sous-total frais uniques{% endif %}</td>
<td class="r">$ {{ money(_sums.onetime) }}</td>
</tr>
</tbody>
</table>
{% endif %}
<div style="margin-top:4mm; font-size:6.5pt; color:#888; padding-left:4px;">
{% if _is_en %}Prices exclude applicable taxes (GST/QST).{% else %}Les prix excluent les taxes applicables (TPS/TVQ).{% endif %}<br>
TPS: 834975559RT0001 &nbsp;|&nbsp; TVQ: 1213765929TQ0001
</div>
</td>
<td class="col-spacer"></td>
<!-- ══ RIGHT COLUMN ══ -->
<td class="col-r">
<!-- Document title -->
<div style="text-align: right; margin-bottom: 3px;">
<div class="doc-label" style="line-height:1;">{{ doc_title }}</div>
</div>
<div class="r-container">
<!-- Meta: quote #, date, validity, account -->
<div class="r-section r-meta">
<table class="r-meta-table">
<tr><td class="ml">{% if _is_en %}Quote #{% else %}N&ordm; soumission{% endif %}</td><td class="mv">{{ quote_number }}</td></tr>
<tr><td class="ml">Date</td><td class="mv">{{ quote_date }}</td></tr>
<tr><td class="ml">{% if _is_en %}Valid until{% else %}Valide jusqu'au{% endif %}</td><td class="mv">{{ valid_till }}</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>
{#- Split totals — recurring + one-time shown separately -#}
{% if has_recurring %}
<div class="r-section r-green">
<table class="ab-table"><tr>
<td>
<div class="ab-label">{% if _is_en %}Recurring total{% else %}Total récurrent{% endif %}</div>
<div class="ab-date">{% if _is_en %}Valid until{% else %}Valide jusqu'au{% endif %} {{ valid_till }}</div>
</td>
<td class="ab-val">$ {{ money(monthly_equiv_val) }}</td>
</tr></table>
</div>
{% endif %}
{% if has_onetime %}
<div class="r-section r-gray">
<table class="ab-table"><tr>
<td>
<div class="ab-label" style="color:#555;">{% if _is_en %}Initial fees{% else %}Frais initiaux{% endif %}</div>
<div class="ab-date">{% if _is_en %}Billed on first invoice{% else %}Facturés à la 1re facture{% endif %}</div>
</td>
<td class="ab-val" style="font-size:14pt;">$ {{ money(_sums.onetime) }}</td>
</tr></table>
</div>
{% endif %}
<!-- QR code → DocuSeal signing link -->
<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 %}Sign online{% else %}Signez en ligne{% endif %}</strong><br>
{% if _is_en %}Scan the QR code or click below{% else %}Scannez le code QR ou cliquez ci-dessous{% endif %}<br>
<strong style="color:#019547">sign.gigafibre.ca</strong>
</td>
</tr></table>
</div>
<!-- Sign CTA button -->
<div class="sign-btn-wrap r-section">
{% if signing_url %}
<a href="{{ signing_url }}" class="sign-btn">
{% if _is_en %}✎ &nbsp;SIGN THIS QUOTE{% else %}✎ &nbsp;SIGNER CETTE SOUMISSION{% endif %}
</a>
{% else %}
<div style="font-size:6.5pt; color:#999; text-align:center; font-style:italic;">
{% if _is_en %}Signing link will be sent by email / SMS{% else %}Lien de signature envoyé par courriel / SMS{% endif %}
</div>
{% endif %}
</div>
<!-- Conditions -->
<div class="r-section r-info-text">
{% if _is_en %}
This quote is valid until {{ valid_till }}. Services are subject to network availability at the service address.
By signing, you accept our <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">terms and conditions</a>.
{% else %}
Cette soumission est valide jusqu'au {{ valid_till }}. Les services sont sujets à la disponibilité du réseau à l'adresse de service.
En signant, vous acceptez nos <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">termes et conditions</a>.
{% endif %}
</div>
<!-- Contact -->
<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> &middot; {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
info@targo.ca &bull; www.targo.ca
</div>
</div>
</td>
</tr></table>
</div>
</body>
</html>
"""
_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,
}
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": "Quotation",
"module": "CRM",
**_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)")
frappe.db.commit()
print(f"\n Done. Print Format '{PRINT_FORMAT_NAME}' ready.")
print(f" Preview: ERPNext → Quotation → Print → Select '{PRINT_FORMAT_NAME}'")
print(f" Note: add gigafibre_utils.api.url_qr_base64(url) to generate QR for DocuSeal links.")