- 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>
358 lines
15 KiB
Python
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("'", "'") | replace("&", "&") | replace("<", "<") | replace(">", ">") | replace(""", '"') 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 }} | {{ 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">✂ 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 }} • {{ company_addr }}, {{ company_city }} • {{ 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 }} • {{ company_addr }}, {{ company_city }} • {{ 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'")
|