Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
301
scripts/generate_ccds_invoice_draft.mjs
Normal file
301
scripts/generate_ccds_invoice_draft.mjs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
const OUTPUT_DIR = path.resolve(process.cwd(), "exports/factures");
|
||||
const OUTPUT_BASENAME = "facture-projet-ccds-portail-associations-6300-2026-06-11";
|
||||
const OUTPUT_PDF = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.pdf`);
|
||||
const OUTPUT_JSON = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.json`);
|
||||
const LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
|
||||
|
||||
const invoice = {
|
||||
status: "PROJET DE FACTURE A VERIFIER AVANT ENVOI",
|
||||
invoiceNumber: "FA-CCDS-2026-06-11-6300",
|
||||
issueDate: "11/06/2026",
|
||||
dueDate: "A completer",
|
||||
issuer: {
|
||||
name: "Nom / raison sociale a completer",
|
||||
addressLines: [
|
||||
"Adresse a completer",
|
||||
"Code postal et ville a completer",
|
||||
],
|
||||
siret: "SIRET a completer",
|
||||
email: "Email a completer",
|
||||
phone: "Telephone a completer",
|
||||
vat: "Regime TVA a completer",
|
||||
},
|
||||
recipient: {
|
||||
name: "Communaute de Communes des Savanes",
|
||||
addressLines: [
|
||||
"Quartier Cabalou",
|
||||
"1 rue Raymond Cresson",
|
||||
"97310 Kourou",
|
||||
],
|
||||
siret: "200 027 548 00029",
|
||||
reference: "Facture adressee a la CCDS",
|
||||
},
|
||||
project: {
|
||||
title: "Portail des associations CCDS",
|
||||
url: "https://www.portail-association973.com",
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
label: "Conception, developpement, integration et finalisation du portail web",
|
||||
details: [
|
||||
"Projet realise pour le portail des associations de la CCDS",
|
||||
"Travail incluant structuration fonctionnelle, developpements, corrections, ajustements metier et mise en coherence generale du site",
|
||||
],
|
||||
quantity: "1",
|
||||
unitPrice: "4 500,00 EUR",
|
||||
total: "4 500,00 EUR",
|
||||
},
|
||||
{
|
||||
label: "Deploiement, exploitation technique, optimisation continue et accompagnement a la mise en production",
|
||||
details: [
|
||||
"Mises a jour successives, stabilisation, finalisation des workflows, mise en place de la preproduction et du tour operationnel",
|
||||
],
|
||||
quantity: "1",
|
||||
unitPrice: "1 500,00 EUR",
|
||||
total: "1 500,00 EUR",
|
||||
},
|
||||
{
|
||||
label: "Location / hebergement des serveurs OVH",
|
||||
details: [
|
||||
"Portail heberge sur infrastructure OVH",
|
||||
"Periode de facturation a completer",
|
||||
],
|
||||
quantity: "1",
|
||||
unitPrice: "300,00 EUR",
|
||||
total: "300,00 EUR",
|
||||
},
|
||||
],
|
||||
totals: {
|
||||
subtotalHt: "6 300,00 EUR",
|
||||
vatAmount: "A verifier selon regime",
|
||||
totalTtc: "6 300,00 EUR si TVA non applicable",
|
||||
},
|
||||
notes: [
|
||||
"Projet initie avec Manus IA puis repris et approfondi sous Codex jusqu'a un niveau de quasi finalisation.",
|
||||
"La montee en competence technique a ete facilitee par William, ingenieur informatique, qui a accompagne l'usage de Manus IA et l'appropriation de termes techniques pour accelerer l'execution.",
|
||||
"Par honnetete de facturation, cet accompagnement n'est pas facture ici comme ligne separee sauf accord explicite contraire.",
|
||||
"Chiffrage retenu pour ce projet : 4 500,00 EUR pour la conception / developpement du portail, 1 500,00 EUR pour le deploiement / finalisation / accompagnement technique, 300,00 EUR pour l'hebergement OVH.",
|
||||
"RIB / IBAN, regime TVA et modalites de reglement a joindre dans la version finale.",
|
||||
],
|
||||
};
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function drawLogo(doc, x, y, width, height) {
|
||||
if (!fs.existsSync(LOGO_PATH)) return;
|
||||
try {
|
||||
doc.image(LOGO_PATH, x, y, { fit: [width, height], align: "left", valign: "center" });
|
||||
} catch {
|
||||
// Keep the invoice usable even if the logo cannot be drawn.
|
||||
}
|
||||
}
|
||||
|
||||
function drawMutedLabel(doc, text, x, y, width) {
|
||||
doc.font("Helvetica-Bold").fontSize(9).fillColor("#64748b").text(text.toUpperCase(), x, y, { width });
|
||||
}
|
||||
|
||||
function drawValue(doc, text, x, y, width, options = {}) {
|
||||
doc.font("Helvetica").fontSize(10.5).fillColor("#0f172a").text(text, x, y, { width, ...options });
|
||||
}
|
||||
|
||||
function drawBox(doc, x, y, width, height, fill = "#ffffff", stroke = "#cbd5e1") {
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 8).fillAndStroke(fill, stroke);
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function getTextHeight(doc, text, width, fontName, fontSize) {
|
||||
doc.font(fontName).fontSize(fontSize);
|
||||
return doc.heightOfString(text, { width });
|
||||
}
|
||||
|
||||
function render() {
|
||||
ensureDir(OUTPUT_DIR);
|
||||
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(invoice, null, 2));
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margins: { top: 38, bottom: 42, left: 38, right: 38 },
|
||||
info: {
|
||||
Title: "Facture brouillon CCDS - Portail des associations",
|
||||
Author: "Codex",
|
||||
Subject: "Facture brouillon a completer pour la CCDS",
|
||||
},
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
const done = new Promise((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const pageLeft = doc.page.margins.left;
|
||||
let y = doc.page.margins.top;
|
||||
|
||||
doc.save();
|
||||
doc.rect(0, 0, doc.page.width, 26).fill("#fff7ed");
|
||||
doc.restore();
|
||||
doc.font("Helvetica-Bold").fontSize(10).fillColor("#c2410c").text(invoice.status, pageLeft, 8, {
|
||||
width: pageWidth,
|
||||
align: "center",
|
||||
});
|
||||
|
||||
drawLogo(doc, pageLeft, y + 6, 88, 88);
|
||||
doc.font("Helvetica-Bold").fontSize(24).fillColor("#0f172a").text("FACTURE", pageLeft + 100, y + 8, { width: 220 });
|
||||
doc.font("Helvetica").fontSize(11).fillColor("#475569").text("Projet Portail des associations CCDS", pageLeft + 100, y + 40, { width: 260 });
|
||||
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(invoice.project.url, pageLeft + 100, y + 58, { width: 280 });
|
||||
|
||||
const headerBoxX = pageLeft + pageWidth - 220;
|
||||
drawBox(doc, headerBoxX, y + 4, 220, 92, "#f8fafc");
|
||||
drawMutedLabel(doc, "Numero", headerBoxX + 14, y + 16, 80);
|
||||
drawValue(doc, invoice.invoiceNumber, headerBoxX + 14, y + 30, 192);
|
||||
drawMutedLabel(doc, "Date", headerBoxX + 14, y + 52, 80);
|
||||
drawValue(doc, invoice.issueDate, headerBoxX + 14, y + 66, 80);
|
||||
drawMutedLabel(doc, "Echeance", headerBoxX + 112, y + 52, 80);
|
||||
drawValue(doc, invoice.dueDate, headerBoxX + 112, y + 66, 94);
|
||||
y += 116;
|
||||
|
||||
const halfGap = 16;
|
||||
const halfWidth = (pageWidth - halfGap) / 2;
|
||||
const leftBoxHeight = 112;
|
||||
const rightBoxHeight = 112;
|
||||
drawBox(doc, pageLeft, y, halfWidth, leftBoxHeight, "#ffffff");
|
||||
drawBox(doc, pageLeft + halfWidth + halfGap, y, halfWidth, rightBoxHeight, "#f8fafc");
|
||||
|
||||
drawMutedLabel(doc, "Emetteur / prestataire", pageLeft + 14, y + 14, halfWidth - 28);
|
||||
drawValue(doc, invoice.issuer.name, pageLeft + 14, y + 32, halfWidth - 28);
|
||||
drawValue(doc, invoice.issuer.addressLines.join("\n"), pageLeft + 14, y + 48, halfWidth - 28);
|
||||
drawValue(doc, `SIRET : ${invoice.issuer.siret}`, pageLeft + 14, y + 78, halfWidth - 28);
|
||||
drawValue(doc, `${invoice.issuer.email} • ${invoice.issuer.phone}`, pageLeft + 14, y + 92, halfWidth - 28);
|
||||
|
||||
const recipientX = pageLeft + halfWidth + halfGap + 14;
|
||||
drawMutedLabel(doc, "Destinataire", recipientX, y + 14, halfWidth - 28);
|
||||
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text(invoice.recipient.name, recipientX, y + 32, {
|
||||
width: halfWidth - 28,
|
||||
});
|
||||
drawValue(doc, invoice.recipient.addressLines.join("\n"), recipientX, y + 50, halfWidth - 28);
|
||||
drawValue(doc, `SIRET : ${invoice.recipient.siret}`, recipientX, y + 92, halfWidth - 28);
|
||||
y += 134;
|
||||
|
||||
drawBox(doc, pageLeft, y, pageWidth, 70, "#eff6ff", "#bfdbfe");
|
||||
drawMutedLabel(doc, "Objet", pageLeft + 14, y + 12, 120);
|
||||
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1e3a8a").text(
|
||||
"Facturation du travail realise sur le projet portail-association973.com et des frais d'hebergement OVH",
|
||||
pageLeft + 14,
|
||||
y + 28,
|
||||
{ width: pageWidth - 28 }
|
||||
);
|
||||
y += 92;
|
||||
|
||||
doc.font("Helvetica-Bold").fontSize(12).fillColor("#0f172a").text("Lignes de facturation", pageLeft, y);
|
||||
y += 18;
|
||||
|
||||
const columns = {
|
||||
desc: pageWidth * 0.62,
|
||||
qty: pageWidth * 0.08,
|
||||
unit: pageWidth * 0.14,
|
||||
total: pageWidth * 0.16,
|
||||
};
|
||||
const headerHeight = 32;
|
||||
doc.save();
|
||||
doc.rect(pageLeft, y, pageWidth, headerHeight).fill("#0f172a");
|
||||
doc.restore();
|
||||
doc.font("Helvetica-Bold").fontSize(9.5).fillColor("#ffffff");
|
||||
doc.text("DESCRIPTION", pageLeft + 10, y + 11, { width: columns.desc - 20 });
|
||||
doc.text("QTE", pageLeft + columns.desc, y + 11, { width: columns.qty, align: "center" });
|
||||
doc.text("PRIX", pageLeft + columns.desc + columns.qty, y + 11, { width: columns.unit, align: "center" });
|
||||
doc.text("TOTAL", pageLeft + columns.desc + columns.qty + columns.unit, y + 11, { width: columns.total, align: "center" });
|
||||
y += headerHeight;
|
||||
|
||||
for (const item of invoice.lineItems) {
|
||||
const detailsText = item.details.map((detail) => `- ${detail}`).join("\n");
|
||||
const contentWidth = columns.desc - 20;
|
||||
const labelHeight = getTextHeight(doc, item.label, contentWidth, "Helvetica-Bold", 10.5);
|
||||
const detailsHeight = getTextHeight(doc, detailsText, contentWidth, "Helvetica", 9.5);
|
||||
const detailsY = y + 12 + labelHeight + 6;
|
||||
const descHeight = 12 + labelHeight + 6 + detailsHeight + 14;
|
||||
const rowHeight = Math.max(78, descHeight);
|
||||
|
||||
[0, columns.desc, columns.desc + columns.qty, columns.desc + columns.qty + columns.unit].forEach((offset, index) => {
|
||||
const width = index === 0
|
||||
? columns.desc
|
||||
: index === 1
|
||||
? columns.qty
|
||||
: index === 2
|
||||
? columns.unit
|
||||
: columns.total;
|
||||
doc.rect(pageLeft + offset, y, width, rowHeight).stroke("#cbd5e1");
|
||||
});
|
||||
|
||||
doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0f172a").text(item.label, pageLeft + 10, y + 10, {
|
||||
width: contentWidth,
|
||||
});
|
||||
doc.font("Helvetica").fontSize(9.5).fillColor("#475569").text(detailsText, pageLeft + 10, detailsY, {
|
||||
width: contentWidth,
|
||||
});
|
||||
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
|
||||
.text(item.quantity, pageLeft + columns.desc, y + rowHeight / 2 - 7, { width: columns.qty, align: "center" })
|
||||
.text(item.unitPrice, pageLeft + columns.desc + columns.qty + 4, y + rowHeight / 2 - 7, { width: columns.unit - 8, align: "center" })
|
||||
.text(item.total, pageLeft + columns.desc + columns.qty + columns.unit + 4, y + rowHeight / 2 - 7, { width: columns.total - 8, align: "center" });
|
||||
|
||||
y += rowHeight;
|
||||
}
|
||||
|
||||
y += 16;
|
||||
const totalsBoxWidth = 220;
|
||||
const totalsBoxX = pageLeft + pageWidth - totalsBoxWidth;
|
||||
drawBox(doc, totalsBoxX, y, totalsBoxWidth, 92, "#f8fafc");
|
||||
const totalRows = [
|
||||
["Sous-total HT", invoice.totals.subtotalHt],
|
||||
["TVA", invoice.totals.vatAmount],
|
||||
["Total TTC", invoice.totals.totalTtc],
|
||||
];
|
||||
let totalY = y + 16;
|
||||
totalRows.forEach(([label, value], index) => {
|
||||
doc.font(index === 2 ? "Helvetica-Bold" : "Helvetica").fontSize(index === 2 ? 11.5 : 10).fillColor("#0f172a");
|
||||
doc.text(label, totalsBoxX + 14, totalY, { width: 100 });
|
||||
doc.text(value, totalsBoxX + 110, totalY, { width: 96, align: "right" });
|
||||
totalY += 24;
|
||||
});
|
||||
y += 112;
|
||||
|
||||
drawBox(doc, pageLeft, y, pageWidth, 138, "#fff7ed", "#fdba74");
|
||||
drawMutedLabel(doc, "Note de contexte et d'honnetete", pageLeft + 14, y + 14, pageWidth - 28);
|
||||
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(
|
||||
invoice.notes.join("\n\n"),
|
||||
pageLeft + 14,
|
||||
y + 32,
|
||||
{ width: pageWidth - 28 }
|
||||
);
|
||||
y += 156;
|
||||
|
||||
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
|
||||
"Document de travail genere pour preparation d'une facture finale. Les montants, coordonnees de l'emetteur, regime TVA et modalites de reglement doivent etre verifies avant envoi.",
|
||||
pageLeft,
|
||||
y,
|
||||
{ width: pageWidth, align: "center" }
|
||||
);
|
||||
|
||||
doc.end();
|
||||
return done.then((buffer) => {
|
||||
fs.writeFileSync(OUTPUT_PDF, buffer);
|
||||
return { pdf: OUTPUT_PDF, json: OUTPUT_JSON };
|
||||
});
|
||||
}
|
||||
|
||||
render()
|
||||
.then(({ pdf, json }) => {
|
||||
process.stdout.write(JSON.stringify({ ok: true, pdf, json }, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(String(error?.stack || error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue