gigafibre-fsm/scripts/migration/setup_invoice_print_format.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00

358 lines
15 KiB
Python

"""
Create custom Print Format for Sales Invoice — Gigafibre/TARGO style.
Inspired by Cogeco layout: summary page 1, details page 2, envelope window address.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_invoice_print_format.py
"""
import os, sys
os.chdir("/home/frappe/frappe-bench/sites")
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
# ── Update Print Settings to Letter size ──
from frappe.installer import update_site_config
frappe.db.set_single_value("Print Settings", "pdf_page_size", "Letter")
frappe.db.commit()
print(" PDF page size set to Letter")
# ── Register the logo file if not exists ──
if not frappe.db.exists("File", {"file_url": "/files/targo-logo-green.svg"}):
f = frappe.get_doc({
"doctype": "File",
"file_name": "targo-logo-green.svg",
"file_url": "/files/targo-logo-green.svg",
"is_private": 0,
})
f.insert(ignore_permissions=True)
frappe.db.commit()
print(" Registered logo file")
PRINT_FORMAT_NAME = "Facture TARGO"
html_template = r"""
{%- set company_name = "TARGO Communications" -%}
{%- set company_addr = "123 rue Principale" -%}
{%- set company_city = "Victoriaville QC G6P 1A1" -%}
{%- set company_tel = "(819) 758-1555" -%}
{%- set company_web = "gigafibre.ca" -%}
{%- set tps_no = "TPS: #819304698RT0001" -%}
{%- set tvq_no = "TVQ: #1215640113TQ0001" -%}
{%- set brand_green = "#019547" -%}
{%- set brand_light = "#e8f5ee" -%}
{%- set is_credit = doc.is_return == 1 -%}
{%- 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_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 -%}
{%- 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 -%}
{# Decode HTML entities in item names #}
{%- macro clean(s) -%}{{ s | replace("&#039;", "'") | replace("&amp;", "&") | replace("&lt;", "<") | replace("&gt;", ">") | replace("&quot;", '"') if s else "" }}{%- endmacro -%}
{# Get customer address from Service Location or address_display #}
{%- set cust_addr = doc.address_display or "" -%}
{%- if not cust_addr and doc.customer_address -%}
{%- set addr_doc = frappe.get_doc("Address", doc.customer_address) -%}
{%- set cust_addr = (addr_doc.address_line1 or "") + "\n" + (addr_doc.city or "") + " " + (addr_doc.state or "") + " " + (addr_doc.pincode or "") -%}
{%- endif -%}
<style>
@page { size: Letter; margin: 12mm 15mm 10mm 15mm; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 9pt; color: #333; line-height: 1.4; }
.inv { width: 100%; }
.hdr-table { width: 100%; margin-bottom: 6px; }
.hdr-table td { vertical-align: top; padding: 0; }
.logo img { height: 36px; }
.doc-title { font-size: 16pt; font-weight: 700; color: {{ brand_green }}; text-align: right; }
.info-table { width: 100%; margin-bottom: 10px; }
.info-table td { vertical-align: top; padding: 2px 0; }
.info-table .lbl { color: #888; font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; }
.info-table .val { font-weight: 600; }
.info-left { width: 50%; }
.info-right { width: 50%; text-align: right; }
.tax-nums { font-size: 7.5pt; color: #888; margin: 4px 0 8px; }
/* Total box — table-based for wkhtmltopdf */
.total-wrap { width: 100%; margin: 10px 0; }
.total-wrap td { padding: 10px 16px; color: white; vertical-align: middle; }
.total-bg { background: {{ brand_green }}; }
.total-bg.credit { background: #dc3545; }
.total-label { font-size: 11pt; }
.total-amount { font-size: 18pt; font-weight: 700; text-align: right; }
/* Summary */
.summary { border: 1.5px solid {{ brand_green }}; padding: 10px 14px; margin: 10px 0; }
.summary-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 6px; }
.s-row { width: 100%; }
.s-row td { padding: 2px 0; font-size: 9pt; }
.s-row .r { text-align: right; }
.s-row .indent td { padding-left: 14px; color: #555; }
.s-row .subtot td { border-top: 1px solid #ddd; padding-top: 4px; font-weight: 600; }
/* Contact */
.contact-table { width: 100%; margin: 10px 0; font-size: 8pt; color: #666; }
.contact-table td { vertical-align: top; padding: 2px 0; }
/* QR */
.qr-wrap { background: {{ brand_light }}; padding: 8px 12px; margin: 8px 0; font-size: 8pt; }
.qr-wrap table td { vertical-align: middle; padding: 2px 8px; }
.qr-box { width: 55px; height: 55px; border: 1px solid #ccc; text-align: center; font-size: 7pt; color: #999; }
/* Coupon */
.coupon-line { border-top: 2px dashed #ccc; margin: 14px 0 4px; font-size: 7pt; color: #999; text-align: center; }
.coupon-table { width: 100%; }
.coupon-table td { text-align: center; font-size: 8pt; vertical-align: middle; padding: 4px; }
.coupon-table .c-lbl { font-size: 6.5pt; color: #888; text-transform: uppercase; }
.coupon-table .c-val { font-weight: 600; }
.coupon-table .c-logo img { height: 22px; }
/* Envelope address */
.env-addr { margin-top: 16px; padding-top: 6px; font-size: 10pt; line-height: 1.5; text-transform: uppercase; }
.footer-line { font-size: 7pt; color: #999; text-align: center; margin-top: 6px; }
/* Return notice */
.return-box { background: #fff3cd; border: 1px solid #ffc107; padding: 6px 12px; margin: 6px 0; font-size: 9pt; }
/* Page 2 — details */
.detail-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; padding: 6px 0; border-bottom: 2px solid {{ brand_green }}; margin-bottom: 4px; }
.dtl { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
.dtl th { text-align: left; padding: 4px 6px; background: {{ brand_light }}; font-weight: 600; color: #555; font-size: 7.5pt; text-transform: uppercase; }
.dtl th.r, .dtl td.r { text-align: right; }
.dtl td { padding: 3px 6px; border-bottom: 1px solid #f0f0f0; }
.dtl tr.tax td { color: #888; font-size: 8pt; border-bottom: none; padding: 1px 6px; }
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #ddd; border-bottom: none; padding-top: 4px; }
.page-break { page-break-before: always; }
</style>
<div class="inv">
<!-- ═══════ PAGE 1: SOMMAIRE ═══════ -->
<!-- HEADER -->
<table class="hdr-table"><tr>
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
<td class="doc-title">{% if is_credit %}NOTE DE CRÉDIT{% else %}FACTURE{% endif %}</td>
</tr></table>
<!-- INFO CLIENT + FACTURE -->
<table class="info-table"><tr>
<td class="info-left">
<div class="lbl">Services fournis à</div>
<div class="val">{{ doc.customer_name or doc.customer }}</div>
{% if cust_addr %}
<div style="margin-top:2px">{{ cust_addr | striptags | replace("\n","<br>") }}</div>
{% endif %}
</td>
<td class="info-right">
<table style="float:right; text-align:right;">
<tr><td class="lbl">Nº de compte</td></tr>
<tr><td class="val">{{ doc.customer }}</td></tr>
<tr><td class="lbl" style="padding-top:6px">Nº de facture</td></tr>
<tr><td class="val">{{ doc.name }}</td></tr>
<tr><td class="lbl" style="padding-top:6px">Date de facturation</td></tr>
<tr><td class="val">{{ date_fr(doc.posting_date) }}</td></tr>
{% if doc.due_date %}
<tr><td class="lbl" style="padding-top:6px">Date d'échéance</td></tr>
<tr><td class="val">{{ date_fr(doc.due_date) }}</td></tr>
{% endif %}
</table>
</td>
</tr></table>
<!-- TAX NUMBERS -->
<div class="tax-nums">{{ tps_no }} &nbsp;|&nbsp; {{ tvq_no }}</div>
<!-- CREDIT NOTE -->
{% if is_credit %}
<div class="return-box">
<strong>Note de crédit</strong>
{% if doc.return_against %} — Renversement de la facture <strong>{{ doc.return_against }}</strong>{% endif %}
</div>
{% endif %}
<!-- MONTANT TOTAL -->
<table class="total-wrap"><tr class="total-bg {% if is_credit %}credit{% endif %}">
<td class="total-label">{% if is_credit %}MONTANT CRÉDITÉ{% else %}MONTANT TOTAL DÛ{% endif %}</td>
<td class="total-amount">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</td>
</tr></table>
<!-- SOMMAIRE DU COMPTE -->
<div class="summary">
<div class="summary-title">SOMMAIRE DU COMPTE</div>
<table class="s-row">
{% if doc.outstanding_amount != doc.grand_total %}
<tr>
<td>Solde antérieur</td>
<td class="r">{{ frappe.utils.fmt_money((doc.outstanding_amount or 0) - (doc.grand_total or 0), currency=doc.currency) }}</td>
</tr>
{% endif %}
<tr><td colspan="2" style="padding-top:6px"><strong>Frais du mois courant</strong></td></tr>
{%- set service_groups = {} -%}
{%- for item in doc.items -%}
{%- set group = item.item_group or "Services" -%}
{%- if group not in service_groups -%}
{%- set _ = service_groups.update({group: 0}) -%}
{%- endif -%}
{%- set _ = service_groups.update({group: service_groups[group] + (item.amount or 0)}) -%}
{%- endfor -%}
{% for group, amount in service_groups.items() %}
<tr class="indent">
<td>{{ group }}</td>
<td class="r">{{ frappe.utils.fmt_money(amount, currency=doc.currency) }}</td>
</tr>
{% endfor %}
<tr class="indent">
<td>Sous-total avant taxes</td>
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
</tr>
{% for tax in doc.taxes %}
<tr class="indent">
<td>{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
</tr>
{% endfor %}
<tr class="subtot">
<td>Total du mois courant</td>
<td class="r">{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</td>
</tr>
</table>
</div>
<!-- CONTACT -->
<table class="contact-table"><tr>
<td>
<strong style="color:#333">Contactez-nous</strong><br>
{{ company_tel }}<br>
{{ company_web }}
</td>
<td style="text-align:right">
<strong style="color:#333">Service à la clientèle</strong><br>
Lun-Ven 8h-17h<br>
info@gigafibre.ca
</td>
</tr></table>
<!-- QR CODE -->
<div class="qr-wrap">
<table><tr>
<td><div class="qr-box"><br>QR</div></td>
<td><strong>Payez en ligne</strong><br>Scannez le code QR ou visitez<br><strong style="color:{{ brand_green }}">{{ company_web }}/payer</strong></td>
</tr></table>
</div>
<!-- COUPON DÉTACHABLE -->
<div class="coupon-line">&#9986; Prière d'expédier cette partie avec votre paiement</div>
<table class="coupon-table"><tr>
<td class="c-logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
<td><div class="c-lbl">Montant versé</div><div class="c-val">________</div></td>
<td><div class="c-lbl">Nº de compte</div><div class="c-val">{{ doc.customer }}</div></td>
<td><div class="c-lbl">Date d'échéance</div><div class="c-val">{{ date_short(doc.due_date) }}</div></td>
<td><div class="c-lbl">Montant à payer</div><div class="c-val">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</div></td>
</tr></table>
<!-- ADRESSE FENÊTRE ENVELOPPE -->
<div class="env-addr">
<strong>{{ doc.customer_name or doc.customer }}</strong><br>
{% if cust_addr %}
{{ cust_addr | striptags | replace("\n","<br>") }}
{% endif %}
</div>
<div class="footer-line">{{ company_name }} &bull; {{ company_addr }}, {{ company_city }} &bull; {{ company_tel }}</div>
<!-- ═══════ PAGE 2: DÉTAILS ═══════ -->
<div class="page-break"></div>
<table class="hdr-table"><tr>
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
<td style="text-align:right; font-size:8pt; color:#888">
Nº de facture: <strong>{{ doc.name }}</strong><br>
Nº de compte: <strong>{{ doc.customer }}</strong><br>
Date: {{ date_fr(doc.posting_date) }}
</td>
</tr></table>
<div class="detail-title">DÉTAILS DE LA FACTURE</div>
<table class="dtl">
<thead><tr>
<th style="width:50%">Description</th>
<th class="r">Qté</th>
<th class="r">Prix unit.</th>
<th class="r">Montant</th>
</tr></thead>
<tbody>
{% for item in doc.items %}
<tr>
<td>{{ clean(item.item_name or item.item_code) }}</td>
<td class="r">{{ item.qty | int if item.qty == (item.qty | int) else item.qty }}</td>
<td class="r">{{ frappe.utils.fmt_money(item.rate, currency=doc.currency) }}</td>
<td class="r">{{ frappe.utils.fmt_money(item.amount, currency=doc.currency) }}</td>
</tr>
{% endfor %}
<tr class="stot">
<td colspan="3">Sous-total avant taxes</td>
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
</tr>
{% for tax in doc.taxes %}
<tr class="tax">
<td colspan="3">{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
</tr>
{% endfor %}
<tr class="stot">
<td colspan="3"><strong>TOTAL</strong></td>
<td class="r"><strong>{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</strong></td>
</tr>
</tbody>
</table>
<!-- FOOTER PAGE 2 -->
<div style="margin-top:30px; text-align:center;">
<img src="/files/targo-logo-green.svg" alt="TARGO" style="height:24px; opacity:0.25">
<div class="footer-line">{{ company_name }} &bull; {{ company_addr }}, {{ company_city }} &bull; {{ company_tel }}</div>
</div>
</div>
"""
# Create or update the Print Format
if frappe.db.exists("Print Format", PRINT_FORMAT_NAME):
doc = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
doc.html = html_template
doc.save(ignore_permissions=True)
print(f" Updated Print Format: {PRINT_FORMAT_NAME}")
else:
doc = frappe.get_doc({
"doctype": "Print Format",
"name": PRINT_FORMAT_NAME,
"__newname": PRINT_FORMAT_NAME,
"doc_type": "Sales Invoice",
"module": "Accounts",
"print_format_type": "Jinja",
"standard": "No",
"custom_format": 1,
"html": html_template,
"default_print_language": "fr",
"disabled": 0,
})
doc.insert(ignore_permissions=True)
print(f" Created Print Format: {PRINT_FORMAT_NAME}")
# Set as default for Sales Invoice
frappe.db.set_value("Property Setter", None, "value", PRINT_FORMAT_NAME, {
"doctype_or_field": "DocType",
"doc_type": "Sales Invoice",
"property": "default_print_format",
})
frappe.db.commit()
print(f" Done! Print Format '{PRINT_FORMAT_NAME}' ready.")
print(" Preview: ERPNext → Sales Invoice → Print → Select 'Facture TARGO'")