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>
481 lines
24 KiB
Python
481 lines
24 KiB
Python
"""
|
||
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("'","'") | 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 -%}
|
||
|
||
{#- ── 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 | 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º 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º 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 %}✎ SIGN THIS QUOTE{% else %}✎ 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> · {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
|
||
info@targo.ca • 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.")
|