initial commit

This commit is contained in:
Matthieu Bessat 2022-02-22 13:37:20 +01:00
commit 76c96f2348
8 changed files with 1587 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
vendor
private

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# Billator
A quick and dirty way to create bills in HTML/PDF format from a list of task with duration in days.
Written in PHP.
May be adapted in the futur to allow for more use cases.
Long term goal is to include this code into a bigger project that manage all the life of a freelancer
## Requirements
- >= PHP 8.0 (not tested with older versions but may work)
- Wkhtmltopdf https://wkhtmltopdf.org/index.html
## Installation
- Run `composer install`
## Usage
- Copy `bill.example.yaml` and create your own Bill using the structure
- If you want you can put your taks in a text file with this format:
```
// file tasks.txt
Foo bar n°1 // task name
0.8 + 2 + 5 // task duration
Foo bar n° 2
3
==== // new category
Foo bar n° 3
4
```
Then generate the categories in a yaml format with the script `generate_categories.php`
- Generate the HTML using the script `generate_html.php`
- Then finally generate the PDF with this cmd :
`wkhtmltopdf bill.html bill.pdf`

21
composer.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "mbess/bill_generator",
"autoload": {
"psr-4": {
"Mbess\\BillGenerator\\": "src/"
}
},
"authors": [
{
"name": "Matthieu Bessat",
"email": "spamfree@matthieubessat.fr"
}
],
"require": {
"symfony/yaml": "^6.0",
"dompdf/dompdf": "^1.2",
"symfony/expression-language": "^6.0",
"twig/twig": "^3.0",
"twig/intl-extra": "^3.3"
}
}

1234
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

51
generate_categories.php Normal file
View file

@ -0,0 +1,51 @@
<?php
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
require 'vendor/autoload.php';
$r = file_get_contents('./tasks.txt');
$lines = explode("\n", $r);
$expr = new ExpressionLanguage();
$days = [];
$categories = [];
$items = [];
$currentItem = [];
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
if (
empty($line) ||
str_starts_with($line, '#') ||
str_starts_with($line,'//')
) {
continue;
}
if (str_starts_with($line, '==')) {
$categories[] = ['name' => 'X', 'tasks' => $items];
$items = [];
$currentItem = [];
continue;
}
$currentItem[] = $lines[$i];
if (count($currentItem) == 2) {
$days = $expr->evaluate($currentItem[1]);
$total += $days;
$items[] = [
'name' => $currentItem[0],
'days' => $days
];
$currentItem = [];
}
}
$categories[] = ['name' => 'X', 'tasks' => $items];
var_dump(count($categories));
echo $total . "\n";
$yaml = Yaml::dump(['categories' => $categories], 5);
file_put_contents('./categories.yaml', $yaml);

41
generate_html.php Normal file
View file

@ -0,0 +1,41 @@
<?php
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Twig\Extra\Intl\IntlExtension;
require 'vendor/autoload.php';
$data = Yaml::parseFile('./bill.yaml');
$totalDays = 0;
foreach ($data['categories'] as $i => $category) {
$sub = 0;
foreach ($category['tasks'] as $task) {
$sub += $task['days'];
}
$totalDays += $sub;
$data['categories'][$i]['total'] = $sub;
}
$total = ceil($totalDays * $data['daily_rate']);
$alreadyPayed = false;
$data['summary'] = [
'days_total' => $totalDays,
'total' => $total,
'already_payed' => $alreadyPayed ? ceil($alreadyPayed) : false,
'remaining' => ceil($total - ($alreadyPayed ? ceil($alreadyPayed) : 0))
];
$loader = new \Twig\Loader\FilesystemLoader('./templates');
$twig = new \Twig\Environment($loader, [
'cache' => false,
]);
$twig->addExtension(new IntlExtension());
$template = $twig->load('bill.html.twig');
$html = $template->render($data);
echo $html;

81
styles/main.css Normal file
View file

@ -0,0 +1,81 @@
body {
background: white;
}
.container {
margin: 0 auto;
width: 80%;
}
.category {
border: 1px solid black;
margin-bottom: 1em;
}
.row {
padding: 3px 5px;
border-top: 1px dashed rgba(0, 0, 0, 0.8);
}
.row {
display: flex;
justify-content: space-between;
}
.category .row:first-of-type {
border-top: 0;
}
.category-name {
font-weight: bold;
}
.sub-total {
display: flex;
justify-content: flex-end;
}
.sub-total > div {
display: flex;
justify-content: space-between;
width: 200px;
}
.header {
display: flex;
justify-content: space-between;
}
.summary {
display: flex;
justify-content: flex-end;
}
.summary-content {
background: rgba(149, 165, 166, 0.3);
padding: 0;
border: 1px solid black;
}
.summary-content .row td {
padding: 5px;
}
.summary-content .row:first-of-type {
border-top: 0;
}
.summary-content tr td:first-of-type {
padding-right: 20px;
}
.summary-content tr td:nth-of-type(2) {
text-align: right;
}
.header-column {
list-style-type: none;
padding-left: 0em;
}
.footer {
margin-top: 60px;
font-size: 0.8em;
}
.banking {
display: flex;
}
.banking div {
margin-right: 4em;
}

111
templates/bill.html.twig Normal file
View file

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Facture</title>
<style>
{% include('../styles/main.css') %}
</style>
</head>
<body>
<div class="container">
<h2>Facture n°{{ id }}</h2>
<div class="header">
<ul class="header-column provider">
<li>Au nom et pour le compte de :</li>
<li><b>{{ provider.name }}</b></li>
{% for line in provider.address %}
<li>{{ line }}</li>
{% endfor %}
<li><a href="telto:{{ provider.mobile }}">{{ provider.mobile_formatted }}</a></li>
<li><a href="mailto:{{ provider.email }}">{{ provider.email }}</a></li>
<li>SIRET : {{ provider.siret }}
<li>TVA intracom : {{ provider.tax_id }}</li>
<li>Code NAF : {{ provider.naf }}</li>
</ul>
<ul class="header-column customer">
<li>Adressé à :</li>
<li><b>{{ customer.name }}</b></li>
{% for line in customer.address %}
<li>{{ line }}</li>
{% endfor %}
<li>SIRET : {{ customer.siret }}
<li>TVA intracom : {{ customer.tax_id }}</li>
<li>Contact : {{ customer.contact.name }}</li>
</ul>
</div>
<p>
Le {{ date | format_datetime('full', 'none', locale='fr') }}, <br>
</p>
<p>
<b>Objet : {{ subject }}</b>
</p>
<br>
{% for category in categories %}
<div class="category">
<div class="row category-name">
<div class="col">
Tâche
</div>
<div class="col">
Durée (en jours)
</div>
</div>
<div class="row category-name">
{{ category.name }}
</div>
{% for task in category.tasks %}
<div class="row task">
<div class="col task-name">{{ task.name }}</div>
<div class="col task-duration">{{ task.days|format_number(locale='fr') }}</div>
</div>
{% endfor %}
<div class="row sub-total">
<div>
<div>Sous-total</div>
<div class="sub-total-amount">{{ category.total|format_number(locale='fr') }}</div>
</div>
</div>
</div>
{% endfor %}
<div class="summary">
<table class="summary-content">
<tr class="row">
<td>Total en jours</td>
<td>{{ summary.days_total|format_number(locale='fr') }} j</td>
</tr>
<tr class="row">
<td>Taux journalier en euros</td>
<td>{{ daily_rate }} €</td>
</tr>
<tr class="row">
<td>TVA (0 %)</td>
<td>0 €</td>
</tr>
<tr class="row">
<td>Total TTC</td>
<td>{{ summary.total|format_number(locale='fr') }} €</td>
</tr>
{% if summary.already_payed %}
<tr class="row">
<td>Déjà payé</td>
<td>{{ summary.already_payed|format_number(locale='fr') }} €</td>
</tr>
{% endif %}
<tr class="row">
<td>Solde</td>
<td><b>{{ summary.remaining|format_number(locale='fr') }} €</b></td>
</tr>
</table>
</div>
<div class="footer">
<div>Coordonnées bancaires :</div>
<div class="banking">
<div>IBAN : FR76 1142 5009 0004 2337 9133 213</div> <div>BIC/SWIFT : CEPAFRPP</div>
</div>
</div>
</div>
</body>
</html>