Add PDF generation for invoices

- Generate both HTML and PDF versions of each customer invoice
- PDFs use printpdf with HTML rendering for consistent styling
- Same layout and formatting as HTML output
This commit is contained in:
2026-03-23 11:04:24 +01:00
parent 4119ad7059
commit 0ebadbdb48
3 changed files with 281 additions and 26 deletions

View File

@@ -7,4 +7,5 @@ edition = "2021"
askama = "0.15.5" askama = "0.15.5"
chrono = "0.4.44" chrono = "0.4.44"
csv = "1.4.0" csv = "1.4.0"
printpdf = "0.9.1"
serde = "1.0.228" serde = "1.0.228"

View File

@@ -6,6 +6,7 @@ use std::fs;
use std::path::Path; use std::path::Path;
mod invoice_generator; mod invoice_generator;
mod pdf;
use invoice_generator::{group_by_customer, read_csv_file, Customer}; use invoice_generator::{group_by_customer, read_csv_file, Customer};
@@ -14,43 +15,43 @@ fn fmt(v: f64) -> String {
} }
#[derive(Clone)] #[derive(Clone)]
struct ProductSummary { pub struct ProductSummary {
name: String, pub name: String,
volume: String, pub volume: String,
amount: String, pub amount: String,
avg_price: String, pub avg_price: String,
} }
#[derive(Clone)] #[derive(Clone)]
struct Summary { pub struct Summary {
total_volume: String, pub total_volume: String,
grand_total: String, pub grand_total: String,
products: Vec<ProductSummary>, pub products: Vec<ProductSummary>,
} }
#[derive(Clone)] #[derive(Clone)]
struct CardData { pub struct CardData {
card_number: String, pub card_number: String,
transactions: Vec<FormattedTransaction>, pub transactions: Vec<FormattedTransaction>,
total_amount: String, pub total_amount: String,
total_volume: String, pub total_volume: String,
} }
#[derive(Clone)] #[derive(Clone)]
struct FormattedTransaction { pub struct FormattedTransaction {
date: String, pub date: String,
quality_name: String, pub quality_name: String,
price: String, pub price: String,
volume: String, pub volume: String,
amount: String, pub amount: String,
receipt: String, pub receipt: String,
} }
#[derive(Clone)] #[derive(Clone)]
struct PreparedCustomer { pub struct PreparedCustomer {
customer_number: String, pub customer_number: String,
cards: Vec<CardData>, pub cards: Vec<CardData>,
summary: Summary, pub summary: Summary,
} }
impl PreparedCustomer { impl PreparedCustomer {
@@ -212,7 +213,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for (customer_num, customer) in customers { for (customer_num, customer) in customers {
let prepared = PreparedCustomer::from_customer(customer); let prepared = PreparedCustomer::from_customer(customer);
let customer_html = CustomerTemplate { let customer_html = CustomerTemplate {
customer: prepared, customer: prepared.clone(),
period: period.clone(), period: period.clone(),
generated_date: generated_date.clone(), generated_date: generated_date.clone(),
} }
@@ -221,6 +222,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let filename = format!("customer_{}.html", customer_num); let filename = format!("customer_{}.html", customer_num);
fs::write(output_dir.join(&filename), customer_html)?; fs::write(output_dir.join(&filename), customer_html)?;
println!("Genererade {}", filename); println!("Genererade {}", filename);
let pdf_filename = format!("customer_{}.pdf", customer_num);
pdf::generate_customer_pdf(
&prepared,
&period,
&generated_date,
&output_dir.join(&pdf_filename),
)?;
println!("Genererade {}", pdf_filename);
} }
println!( println!(

244
src/pdf.rs Normal file
View File

@@ -0,0 +1,244 @@
use printpdf::*;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use crate::PreparedCustomer;
pub fn generate_customer_pdf(
customer: &PreparedCustomer,
period: &str,
generated_date: &str,
output_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let html = format!(
r#"<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<title>Faktura - Kund {}</title>
<style>
@page {{
size: A4;
margin: 15mm;
}}
* {{ box-sizing: border-box; }}
body {{
font-family: Arial, sans-serif;
font-size: 12px;
margin: 0;
padding: 20px;
}}
.header {{
display: flex;
justify-content: space-between;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 15px;
}}
.header h1 {{
margin: 0;
font-size: 20px;
}}
.header .meta {{
text-align: right;
font-size: 11px;
color: #666;
}}
.summary {{
background: #f5f5f5;
border: 1px solid #ccc;
padding: 12px;
margin-bottom: 15px;
}}
.summary h2 {{
margin: 0 0 10px 0;
font-size: 14px;
}}
table {{
width: 100%;
border-collapse: collapse;
font-size: 11px;
}}
th, td {{
padding: 5px 8px;
text-align: left;
}}
th {{
background: #e0e0e0;
font-weight: bold;
}}
.grand-total-row td {{
font-weight: bold;
border-top: 2px solid #333;
background: #f0f0f0;
}}
.card-section {{
margin-bottom: 15px;
border: 1px solid #ccc;
}}
.card-header {{
background: #f0f0f0;
padding: 8px 12px;
font-weight: bold;
}}
.card-summary {{
padding: 6px 12px;
font-size: 11px;
background: #fafafa;
}}
.grand-total {{
background: #333;
color: white;
padding: 12px 15px;
font-size: 14px;
font-weight: bold;
text-align: right;
}}
</style>
</head>
<body>
<div class="header">
<div>
<h1>Faktura - Kund {}</h1>
<div>Period: {}</div>
</div>
<div class="meta">
<div>Genererad: {}</div>
</div>
</div>
<div class="summary">
<h2>Sammanfattning</h2>
<table>
<thead>
<tr>
<th>Produkt</th>
<th style="text-align:right">Volym (L)</th>
<th style="text-align:right">Belopp</th>
<th style="text-align:right">Snittpris/L</th>
</tr>
</thead>
<tbody>
{}
<tr class="grand-total-row">
<td>Totalt</td>
<td style="text-align:right">{}</td>
<td style="text-align:right">{} Kr</td>
<td></td>
</tr>
</tbody>
</table>
</div>
{}
</body>
</html>"#,
customer.customer_number,
customer.customer_number,
period,
generated_date,
generate_product_rows(&customer.summary.products),
customer.summary.total_volume,
customer.summary.grand_total,
generate_card_sections(&customer.cards),
);
let mut warnings = Vec::new();
let doc = PdfDocument::from_html(
&html,
&BTreeMap::new(),
&BTreeMap::new(),
&GeneratePdfOptions::default(),
&mut warnings,
)?;
let file = File::create(output_path)?;
let mut writer = BufWriter::new(file);
let mut warnings = Vec::new();
doc.save_writer(&mut writer, &PdfSaveOptions::default(), &mut warnings);
Ok(())
}
fn generate_product_rows(products: &[crate::ProductSummary]) -> String {
products
.iter()
.map(|p| {
format!(
r#"<tr>
<td>{}</td>
<td style="text-align:right">{}</td>
<td style="text-align:right">{} Kr</td>
<td style="text-align:right">{} Kr</td>
</tr>"#,
p.name, p.volume, p.amount, p.avg_price
)
})
.collect::<Vec<_>>()
.join("\n ")
}
fn generate_card_sections(cards: &[crate::CardData]) -> String {
cards
.iter()
.map(|card| {
format!(
r#"<div class="card-section">
<div class="card-header">Kort: {} | {} transaktioner</div>
<div class="card-summary">Summa: {} Kr | Volym: {} L</div>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Produkt</th>
<th style="text-align:right">Pris/L</th>
<th style="text-align:right">Volym</th>
<th style="text-align:right">Belopp</th>
<th>Kvitto</th>
</tr>
</thead>
<tbody>
{}
<tr style="font-weight:bold;background:#f9f9f9">
<td colspan="3">Kortsumma</td>
<td style="text-align:right">{}</td>
<td style="text-align:right">{}</td>
<td></td>
</tr>
</tbody>
</table>
</div>"#,
card.card_number,
card.transactions.len(),
card.total_amount,
card.total_volume,
generate_transaction_rows(&card.transactions),
card.total_volume,
card.total_amount,
)
})
.collect::<Vec<_>>()
.join("\n\n ")
}
fn generate_transaction_rows(transactions: &[crate::FormattedTransaction]) -> String {
transactions
.iter()
.map(|tx| {
format!(
r#"<tr>
<td>{}</td>
<td>{}</td>
<td style="text-align:right">{}</td>
<td style="text-align:right">{}</td>
<td style="text-align:right">{}</td>
<td>{}</td>
</tr>"#,
tx.date, tx.quality_name, tx.price, tx.volume, tx.amount, tx.receipt
)
})
.collect::<Vec<_>>()
.join("\n ")
}