feat(erp): facturation KissMetrics en dollars, part différée M3 retrouvée, et le format de lettre commerciale #97
@@ -0,0 +1,79 @@
|
|||||||
|
# Correspondance commerciale — le format maison
|
||||||
|
|
||||||
|
Le gabarit des lettres qu'Arcodange adresse à ses clients et prospects. Fixé le
|
||||||
|
24 août 2026 avec le dossier KissMetrics, et validé par l'opérateur : « on peut
|
||||||
|
se souvenir de ce format pour les communications avec KM et futurs
|
||||||
|
prospects/clients ».
|
||||||
|
|
||||||
|
## Produire une lettre
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 correspondance/lettre.py corps.html sortie.pdf \
|
||||||
|
--titre "Contracts and invoices — cycles M1 to M4" \
|
||||||
|
--date "24 August 2026" --prenom Evan \
|
||||||
|
--destinataire "Evan Sforzo" --fonction "Chief Executive Officer" \
|
||||||
|
--societe "Kissmetrics Inc." \
|
||||||
|
--adresse "2850 34th Street North, 307 — St. Petersburg, Florida 33713 — United States" \
|
||||||
|
--copie "Hendrik Rootering" \
|
||||||
|
--pied "Arcodange × Kissmetrics Inc. — 24 August 2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
`corps.html` ne porte que le corps — les `<h2>`, `<p>`, `<table>`, encadrés.
|
||||||
|
L'en-tête, le bloc destinataire, la signature et le pied viennent du gabarit.
|
||||||
|
Sans `--copie`, la ligne « cc » disparaît entièrement.
|
||||||
|
|
||||||
|
Le script vérifie le nombre de pages et proteste au-delà de deux : une lettre
|
||||||
|
d'affaires qui déborde ne se lit pas. Resserrer le CORPS, jamais la typographie.
|
||||||
|
|
||||||
|
## Les choix, et pourquoi
|
||||||
|
|
||||||
|
**Charter pour le texte, Optima pour les titres.** Charter a été dessinée par
|
||||||
|
Matthew Carter pour tenir le petit corps là où d'autres se délitent — elle reste
|
||||||
|
lisible à l'écran comme sur papier bon marché. Optima lui donne un contrepoint
|
||||||
|
humaniste sans raideur. Les deux sont incorporées au PDF : le rendu est le même
|
||||||
|
chez le destinataire, quelle que soit sa machine. Chiffres elzéviriens activés,
|
||||||
|
pour que les montants dans le texte s'alignent au lieu de faire des bâtons.
|
||||||
|
|
||||||
|
**Deux encadrés, deux usages.** Le gris (`.calme`) porte une question ou une
|
||||||
|
demande — elle ne doit pas se noyer dans un paragraphe. Le rouge sourd
|
||||||
|
(`.encart`) porte ce qui doit être lu même en diagonale. **Un seul par lettre** ;
|
||||||
|
deux, et plus rien ne ressort.
|
||||||
|
|
||||||
|
**Les tableaux portent un `<thead>`.** Sans lui, un tableau qui se coupe entre
|
||||||
|
deux pages perd son en-tête et devient illisible. Avec, il se répète.
|
||||||
|
|
||||||
|
**Les trois emblèmes 🏹💻🪽** viennent du site — *« Gabriel 🪽 Radureau, pour 🏹
|
||||||
|
réussir vos projets 💻 »*. L'arc et l'ange sont dans le nom lui-même. Ils
|
||||||
|
figurent en marque sous le mot-marque, et l'aile seule entre prénom et nom dans
|
||||||
|
la signature.
|
||||||
|
|
||||||
|
## Le piège des emblèmes
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **`weasyprint` ne sait pas rendre Apple Color Emoji.** C'est un format bitmap
|
||||||
|
> `sbix` qu'il ignore : les trois emblèmes sortent en carrés vides, sans erreur
|
||||||
|
> ni avertissement. Vérifié le 24/08/2026.
|
||||||
|
|
||||||
|
D'où `emblemes/*.png`, rendus une fois pour toutes par `test/emoji2png.ts`, qui
|
||||||
|
passe par Chromium — lui lit la police système. Pour les régénérer ou en ajouter :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test && deno run -A emoji2png.ts ../correspondance/emblemes 🏹 💻 🪽
|
||||||
|
```
|
||||||
|
|
||||||
|
Les PNG sont incorporés en base64 **à la génération**, pas dans le gabarit : un
|
||||||
|
gabarit de 400 Ko dont 97 % de charabia ne se relit pas. Le PDF produit, lui,
|
||||||
|
reste autonome.
|
||||||
|
|
||||||
|
## Ce que le format ne fait pas
|
||||||
|
|
||||||
|
Il ne remplace pas le message d'accompagnement. La lettre est le document qu'on
|
||||||
|
joint ; le mot sur Slack ou par courriel reste séparé, plus court, et dit
|
||||||
|
pourquoi on écrit. Les deux doivent rester d'accord — vérifier que la lettre ne
|
||||||
|
mentionne aucune pièce absente de l'envoi.
|
||||||
|
|
||||||
|
## Exemple de référence
|
||||||
|
|
||||||
|
`1_DOCUMENTS/prospects/KissMetrics/relances/2026-08-24_lettre_KM.html` — la
|
||||||
|
lettre du 24/08/2026, dont ce gabarit est extrait. Elle se régénère à
|
||||||
|
l'identique, au mot près, ce qui est le test du gabarit.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Produit une lettre commerciale Arcodange en PDF, depuis le gabarit maison.
|
||||||
|
|
||||||
|
POURQUOI CE SCRIPT EXISTE. Deux choses ne se font pas à la main sans se tromper.
|
||||||
|
|
||||||
|
1. `weasyprint` NE SAIT PAS rendre Apple Color Emoji : c'est un format bitmap
|
||||||
|
`sbix` qu'il ignore, et les trois emblèmes 🏹💻🪽 sortent en carrés vides.
|
||||||
|
Il faut les incorporer en images. Elles sont ici en PNG, rendues une fois
|
||||||
|
pour toutes par `test/emoji2png.ts` (Chromium, lui, lit la police système).
|
||||||
|
2. Un gabarit qui porterait ces images en base64 pèserait 400 Ko dont 97 % de
|
||||||
|
charabia. On les garde en fichiers et on les incorpore À LA GÉNÉRATION,
|
||||||
|
pour que le gabarit reste relisible et que le PDF reste autonome.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 correspondance/lettre.py corps.html sortie.pdf \
|
||||||
|
--titre "Contracts and invoices — cycles M1 to M4" \
|
||||||
|
--date "24 August 2026" --prenom Evan \
|
||||||
|
--destinataire "Evan Sforzo" --fonction "Chief Executive Officer" \
|
||||||
|
--societe "Kissmetrics Inc." \
|
||||||
|
--adresse "2850 34th Street North, 307 — St. Petersburg, Florida 33713 — United States" \
|
||||||
|
--copie "Hendrik Rootering" \
|
||||||
|
--pied "Arcodange × Kissmetrics Inc. — 24 August 2026"
|
||||||
|
|
||||||
|
`corps.html` ne contient que le corps : les <h2>, <p>, <table>, encadrés. L'en-tête,
|
||||||
|
le bloc destinataire, la signature et le pied de page viennent du gabarit.
|
||||||
|
Sans `--copie`, la ligne « cc » disparaît.
|
||||||
|
"""
|
||||||
|
import argparse, base64, pathlib, re, subprocess, sys
|
||||||
|
|
||||||
|
ICI = pathlib.Path(__file__).parent
|
||||||
|
GABARIT = ICI / "lettre.template.html"
|
||||||
|
EMBLEMES = {"ARC": "arc.png", "LAPTOP": "laptop.png", "AILE": "aile.png"}
|
||||||
|
|
||||||
|
|
||||||
|
def data_uri(chemin: pathlib.Path) -> str:
|
||||||
|
return "data:image/png;base64," + base64.b64encode(chemin.read_bytes()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
a = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
a.add_argument("corps"); a.add_argument("sortie")
|
||||||
|
for champ in ("titre", "date", "prenom", "destinataire", "fonction",
|
||||||
|
"societe", "adresse", "pied"):
|
||||||
|
a.add_argument(f"--{champ}", required=True)
|
||||||
|
a.add_argument("--copie", default="")
|
||||||
|
a.add_argument("--garder-html", action="store_true",
|
||||||
|
help="conserve le HTML intermédiaire à côté du PDF")
|
||||||
|
o = a.parse_args()
|
||||||
|
|
||||||
|
html = GABARIT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for cle, fichier in EMBLEMES.items():
|
||||||
|
p = ICI / "emblemes" / fichier
|
||||||
|
if not p.exists():
|
||||||
|
print(f"emblème manquant : {p}\n"
|
||||||
|
f" le régénérer : cd test && deno run -A emoji2png.ts <dossier> 🏹 💻 🪽",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
html = html.replace("{{" + cle + "}}", data_uri(p))
|
||||||
|
|
||||||
|
for cle, val in (("TITRE", o.titre), ("DATE", o.date), ("PRENOM", o.prenom),
|
||||||
|
("DESTINATAIRE", o.destinataire), ("FONCTION", o.fonction),
|
||||||
|
("SOCIETE", o.societe), ("ADRESSE", o.adresse),
|
||||||
|
("COPIE", o.copie), ("PIED", o.pied)):
|
||||||
|
html = html.replace("{{" + cle + "}}", val)
|
||||||
|
|
||||||
|
# Pas de destinataire en copie : on retire la ligne entière, pas seulement
|
||||||
|
# son contenu, sinon il reste un « cc » orphelin.
|
||||||
|
if not o.copie:
|
||||||
|
html = re.sub(r'<br>\s*<span class="a"[^>]*>cc</span>\s* \s*', "", html)
|
||||||
|
|
||||||
|
# Le corps remplace tout ce qui sépare le sous-titre de la signature.
|
||||||
|
corps = pathlib.Path(o.corps).read_text(encoding="utf-8")
|
||||||
|
deb = html.index("<h1>")
|
||||||
|
fin = html.index('<div class="signature">')
|
||||||
|
entete = html[deb:html.index("<p>Dear ")]
|
||||||
|
html = html[:deb] + entete + f"<p>Dear {o.prenom},</p>\n\n" + corps + "\n\n" + html[fin:]
|
||||||
|
|
||||||
|
tmp = pathlib.Path(o.sortie).with_suffix(".html")
|
||||||
|
tmp.write_text(html, encoding="utf-8")
|
||||||
|
r = subprocess.run(["weasyprint", str(tmp), o.sortie])
|
||||||
|
if r.returncode == 0 and not o.garder_html:
|
||||||
|
tmp.unlink()
|
||||||
|
if r.returncode == 0:
|
||||||
|
pages = subprocess.run(["pdfinfo", o.sortie], capture_output=True, text=True).stdout
|
||||||
|
n = next((l.split()[1] for l in pages.splitlines() if l.startswith("Pages")), "?")
|
||||||
|
print(f"ok — {o.sortie} ({n} page(s))")
|
||||||
|
if n not in ("1", "2"):
|
||||||
|
print(" ATTENTION : au-delà de deux pages, une lettre d'affaires se lit mal. "
|
||||||
|
"Resserrer le corps plutôt que la typographie.", file=sys.stderr)
|
||||||
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<title>{{TITRE}} — Arcodange</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 15mm 18mm 13mm 18mm;
|
||||||
|
@bottom-center { content: "{{PIED}} — page " counter(page) " / " counter(pages);
|
||||||
|
font-family: "Optima", "Seravek", sans-serif; font-size: 7.8pt;
|
||||||
|
letter-spacing: .4px; color: #8a8a8a; } }
|
||||||
|
body { font-family: "Charter", "Bitstream Charter", "Iowan Old Style", Georgia, serif;
|
||||||
|
font-size: 9.9pt; line-height: 1.44; color: #14161a;
|
||||||
|
font-feature-settings: "kern" 1, "liga" 1, "onum" 1; }
|
||||||
|
.entete { border-bottom: 1.5px solid #14161a; padding-bottom: 8px; margin-bottom: 16px;
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.entete .nom { font-family: "Optima", "Seravek", sans-serif; font-size: 17pt;
|
||||||
|
font-weight: 600; letter-spacing: 3.5px; }
|
||||||
|
.marque { margin-top: 7px; }
|
||||||
|
.marque img { height: 16px; vertical-align: -3px; margin-right: 7px; }
|
||||||
|
.signature .qui img { height: 13px; vertical-align: -2px; margin: 0 1px; }
|
||||||
|
.entete .mentions { font-family: "Optima", "Seravek", sans-serif; font-size: 8pt; color: #4a4a4a; line-height: 1.45; text-align: right; }
|
||||||
|
.dest { margin-bottom: 16px; font-size: 10pt; }
|
||||||
|
.dest .a { font-family: "Optima", "Seravek", sans-serif; color: #8a8a8a; font-size: 7.8pt; letter-spacing: .6px; text-transform: uppercase; }
|
||||||
|
h1 { font-family: "Optima", "Seravek", sans-serif; font-size: 14pt; font-weight: 600;
|
||||||
|
margin: 0 0 3px; letter-spacing: .2px; }
|
||||||
|
h1 + .sous { font-style: italic; color: #4a4a4a; margin: 0 0 16px; font-size: 10pt; }
|
||||||
|
h2 { font-family: "Optima", "Seravek", sans-serif; font-size: 10.2pt; font-weight: 600;
|
||||||
|
page-break-after: avoid; margin: 14px 0 5px; padding-bottom: 3px; border-bottom: 1px solid #d8d8d8;
|
||||||
|
letter-spacing: .4px; }
|
||||||
|
p { margin: 0 0 7px; text-align: justify; }
|
||||||
|
strong { font-weight: bold; }
|
||||||
|
em { font-style: italic; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 9px 0 10px; font-size: 8.7pt; }
|
||||||
|
th, td { border-bottom: 1px solid #dcdcdc; padding: 3.5px 8px; text-align: left; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
th { font-family: "Optima", "Seravek", sans-serif;
|
||||||
|
background: #f4f4f2; border-bottom: 1px solid #999; font-weight: 600; font-size: 8.2pt;
|
||||||
|
letter-spacing: .5px; text-transform: uppercase; color: #333; }
|
||||||
|
td.n { text-align: right; white-space: nowrap; }
|
||||||
|
tr.due td { background: #fdf6f2; font-weight: bold; }
|
||||||
|
.encart { border-left: 3px solid #8a1c1c; background: #fbf6f5; padding: 8px 12px; margin: 11px 0;
|
||||||
|
font-size: 9.4pt; }
|
||||||
|
.calme { border-left: 3px solid #b8b8b0; background: #f8f8f6; padding: 8px 12px; margin: 11px 0;
|
||||||
|
font-size: 9.4pt; }
|
||||||
|
.signature { margin-top: 17px; page-break-inside: avoid; }
|
||||||
|
.signature .qui { font-family: "Optima", "Seravek", sans-serif; font-weight: 600; font-size: 11pt; }
|
||||||
|
.signature .role { font-family: "Optima", "Seravek", sans-serif; font-size: 9pt; color: #555; }
|
||||||
|
</style></head><body>
|
||||||
|
|
||||||
|
<div class="entete">
|
||||||
|
<div><div class="nom">ARCODANGE</div>
|
||||||
|
<div class="marque"><img src="{{ARC}}" alt=""><img src="{{LAPTOP}}" alt=""><img src="{{AILE}}" alt=""></div></div>
|
||||||
|
<div class="mentions">
|
||||||
|
SARL au capital de 1 000 € — SIREN 999 657 455 R.C.S. Évry<br>
|
||||||
|
73 boulevard de l'Yerres, 91000 Évry-Courcouronnes, France<br>
|
||||||
|
VAT FR00 999 657 455 — [email protected]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dest">
|
||||||
|
<span class="a">To</span><br>
|
||||||
|
<strong>{{DESTINATAIRE}}</strong> — {{FONCTION}}, {{SOCIETE}}<br>
|
||||||
|
{{ADRESSE}}<br>
|
||||||
|
<span class="a" style="font-size:7.4pt">cc</span> {{COPIE}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>{{TITRE}}</h1>
|
||||||
|
<p class="sous">{{DATE}}</p>
|
||||||
|
|
||||||
|
<p>Dear {{PRENOM}},</p>
|
||||||
|
|
||||||
|
<!-- ═══ CORPS ═══════════════════════════════════════════════════════════════
|
||||||
|
Le contenu, et rien d'autre, change d'une lettre à l'autre. Ce qui suit
|
||||||
|
est l'exemple du 24/08/2026 (dossier KissMetrics), gardé pour montrer les
|
||||||
|
éléments disponibles. Le remplacer intégralement.
|
||||||
|
|
||||||
|
<h2>1 — Titre de section</h2> numérotées, c'est une lettre d'affaires
|
||||||
|
<p>…</p>
|
||||||
|
<div class="calme">…</div> encadré gris : une question, une demande
|
||||||
|
<div class="encart">…</div> encadré rouge sourd : ce qui doit être vu
|
||||||
|
<table>…</table> <thead> obligatoire : l'en-tête se répète
|
||||||
|
<tr class="due">…</tr> ligne mise en avant dans un tableau
|
||||||
|
════════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
|
<h2>1 — Une section</h2>
|
||||||
|
|
||||||
|
<p>Corps de texte en Charter. Les <strong>montants</strong> et les points qui
|
||||||
|
portent la décision se mettent en gras ; les citations en <em>italique</em>.</p>
|
||||||
|
|
||||||
|
<div class="calme">
|
||||||
|
<p style="margin:0"><strong>Une question posée franchement</strong> se met dans
|
||||||
|
un encadré gris — elle ne doit pas se noyer dans un paragraphe.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="encart">
|
||||||
|
<p style="margin:0">L'encadré rouge sourd est réservé à ce qui doit être lu
|
||||||
|
même en diagonale. <strong>Un par lettre, pas deux.</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<p class="qui">Gabriel <img src="{{AILE}}" alt=""> Radureau</p>
|
||||||
|
<p class="role">Gérant — Arcodange</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Rend un emoji en PNG transparent via Chromium, qui sait lire Apple Color
|
||||||
|
// Emoji là où weasyprint échoue (format bitmap sbix, non géré).
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
const [outDir, ...emojis] = Deno.args;
|
||||||
|
const b = await chromium.launch({ headless: true });
|
||||||
|
const p = await (await b.newContext({ deviceScaleFactor: 8 })).newPage();
|
||||||
|
for (const [i, e] of emojis.entries()) {
|
||||||
|
await p.setContent(
|
||||||
|
`<body style="margin:0"><span id="g" style="font-family:'Apple Color Emoji';` +
|
||||||
|
`font-size:64px;line-height:1;display:inline-block">${e}</span></body>`);
|
||||||
|
const el = p.locator("#g");
|
||||||
|
const f = `${outDir}/emoji-${i + 1}.png`;
|
||||||
|
await el.screenshot({ path: f, omitBackground: true });
|
||||||
|
console.log(` ${e} -> ${f}`);
|
||||||
|
}
|
||||||
|
await b.close();
|
||||||
Reference in New Issue
Block a user