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:
@@ -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"
|
||||||
|
|||||||
62
src/main.rs
62
src/main.rs
@@ -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
244
src/pdf.rs
Normal 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 ")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user