ebia 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ebia/__init__.py +4 -0
- ebia/cli.py +125 -0
- ebia/parser.py +115 -0
- ebia/xls_generator.py +282 -0
- ebia-0.1.4.dist-info/METADATA +147 -0
- ebia-0.1.4.dist-info/RECORD +9 -0
- ebia-0.1.4.dist-info/WHEEL +5 -0
- ebia-0.1.4.dist-info/entry_points.txt +2 -0
- ebia-0.1.4.dist-info/top_level.txt +1 -0
ebia/__init__.py
ADDED
ebia/cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .parser import extract_invoice_fields
|
|
7
|
+
from .xls_generator import (
|
|
8
|
+
generate_xlsx_by_month_day,
|
|
9
|
+
generate_xlsx_single,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None) -> int:
|
|
14
|
+
ap = argparse.ArgumentParser(prog="ebia")
|
|
15
|
+
|
|
16
|
+
ap.add_argument("--path", required=True, help="PDF file path OR a folder containing PDFs")
|
|
17
|
+
ap.add_argument(
|
|
18
|
+
"--out",
|
|
19
|
+
default=None,
|
|
20
|
+
help=(
|
|
21
|
+
"Single-PDF mode: output .xlsx file path (default: output.xlsx). "
|
|
22
|
+
"Folder mode: output directory where one YYYY-MM.xlsx is created per month "
|
|
23
|
+
"(default: current directory)."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ap.add_argument("--tva", type=float, default=0.20, help="TVA rate (default 0.20)")
|
|
28
|
+
|
|
29
|
+
# Incrémentation pour dossier
|
|
30
|
+
ap.add_argument("--start-piece", type=int, default=1, help="Starting Pièce number (default 1)")
|
|
31
|
+
ap.add_argument(
|
|
32
|
+
"--start-document", type=int, default=1, help="Starting Document number (default 1)"
|
|
33
|
+
)
|
|
34
|
+
ap.add_argument("--piece-width", type=int, default=4, help="Pièce zero-fill width (default 4)")
|
|
35
|
+
ap.add_argument(
|
|
36
|
+
"--document-width", type=int, default=5, help="Document zero-fill width (default 5)"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Pour un seul PDF (optionnel : override)
|
|
40
|
+
ap.add_argument("--piece", default=None, help="Override Pièce for single PDF (e.g. 0000)")
|
|
41
|
+
ap.add_argument(
|
|
42
|
+
"--document", default=None, help="Override Document for single PDF (e.g. 00000)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
ap.add_argument("--no-headers", action="store_true", help="Generate Excel without header row")
|
|
46
|
+
ap.add_argument(
|
|
47
|
+
"--recursive", action="store_true", help="Scan subfolders for PDFs (folder mode)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
args = ap.parse_args(argv)
|
|
51
|
+
|
|
52
|
+
p = Path(args.path)
|
|
53
|
+
include_headers = not args.no_headers
|
|
54
|
+
|
|
55
|
+
if p.is_file():
|
|
56
|
+
# Single PDF mode
|
|
57
|
+
data = extract_invoice_fields(str(p))
|
|
58
|
+
|
|
59
|
+
out_file = args.out if args.out is not None else "output.xlsx"
|
|
60
|
+
|
|
61
|
+
# piece/document: si non fournis, on utilise start_* avec widths
|
|
62
|
+
if args.piece is None:
|
|
63
|
+
piece = str(args.start_piece).zfill(args.piece_width)
|
|
64
|
+
else:
|
|
65
|
+
piece = args.piece
|
|
66
|
+
|
|
67
|
+
if args.document is None:
|
|
68
|
+
document = str(args.start_document).zfill(args.document_width)
|
|
69
|
+
else:
|
|
70
|
+
document = args.document
|
|
71
|
+
|
|
72
|
+
generate_xlsx_single(
|
|
73
|
+
data,
|
|
74
|
+
out_file,
|
|
75
|
+
piece=piece,
|
|
76
|
+
document=document,
|
|
77
|
+
tva_rate=args.tva,
|
|
78
|
+
include_headers=include_headers,
|
|
79
|
+
)
|
|
80
|
+
print(f"OK (single) -> {out_file}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
if p.is_dir():
|
|
84
|
+
# Folder mode
|
|
85
|
+
if args.recursive:
|
|
86
|
+
pdfs = sorted(p.rglob("*.pdf")) + sorted(p.rglob("*.PDF"))
|
|
87
|
+
else:
|
|
88
|
+
pdfs = sorted(p.glob("*.pdf")) + sorted(p.glob("*.PDF"))
|
|
89
|
+
|
|
90
|
+
if not pdfs:
|
|
91
|
+
raise SystemExit(f"No PDF files found in folder: {p}")
|
|
92
|
+
|
|
93
|
+
invoice_dicts = []
|
|
94
|
+
skipped = 0
|
|
95
|
+
|
|
96
|
+
for pdf in pdfs:
|
|
97
|
+
try:
|
|
98
|
+
invoice_dicts.append(extract_invoice_fields(str(pdf)))
|
|
99
|
+
except Exception as e:
|
|
100
|
+
skipped += 1
|
|
101
|
+
print(f"[SKIP] {pdf}: {e}")
|
|
102
|
+
|
|
103
|
+
out_dir = args.out if args.out is not None else "./reports"
|
|
104
|
+
|
|
105
|
+
generated = generate_xlsx_by_month_day(
|
|
106
|
+
invoice_dicts,
|
|
107
|
+
out_dir,
|
|
108
|
+
start_piece=args.start_piece,
|
|
109
|
+
start_document=args.start_document,
|
|
110
|
+
piece_width=args.piece_width,
|
|
111
|
+
document_width=args.document_width,
|
|
112
|
+
tva_rate=args.tva,
|
|
113
|
+
include_headers=include_headers,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
print(f"OK (folder) | parsed={len(invoice_dicts)} skipped={skipped}")
|
|
117
|
+
for f in generated:
|
|
118
|
+
print(f" -> {f}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
raise SystemExit(f"Invalid path: {p}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
ebia/parser.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import pdfplumber
|
|
5
|
+
|
|
6
|
+
MONTHS_FR = {
|
|
7
|
+
"janvier": 1,
|
|
8
|
+
"février": 2,
|
|
9
|
+
"fevrier": 2,
|
|
10
|
+
"mars": 3,
|
|
11
|
+
"avril": 4,
|
|
12
|
+
"mai": 5,
|
|
13
|
+
"juin": 6,
|
|
14
|
+
"juillet": 7,
|
|
15
|
+
"août": 8,
|
|
16
|
+
"aout": 8,
|
|
17
|
+
"septembre": 9,
|
|
18
|
+
"octobre": 10,
|
|
19
|
+
"novembre": 11,
|
|
20
|
+
"décembre": 12,
|
|
21
|
+
"decembre": 12,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
__all__ = ["extract_invoice_fields"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize(value: str) -> float:
|
|
28
|
+
value = (
|
|
29
|
+
value.replace("\u00a0", " ") # NBSP → space
|
|
30
|
+
.replace("\u202f", " ") # narrow no-break space → space
|
|
31
|
+
.replace("\n", "") # PDF line-wrap artefact
|
|
32
|
+
.replace(" ", "") # remove thousand-separator spaces
|
|
33
|
+
.replace(",", ".")
|
|
34
|
+
) # French decimal comma → point
|
|
35
|
+
return float(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_french_date(date_str: str) -> str | None:
|
|
39
|
+
s = date_str.lower()
|
|
40
|
+
s = (
|
|
41
|
+
s.replace("é", "e")
|
|
42
|
+
.replace("è", "e")
|
|
43
|
+
.replace("ê", "e")
|
|
44
|
+
.replace("à", "a")
|
|
45
|
+
.replace("â", "a")
|
|
46
|
+
.replace("î", "i")
|
|
47
|
+
.replace("ï", "i")
|
|
48
|
+
.replace("ô", "o")
|
|
49
|
+
.replace("û", "u")
|
|
50
|
+
.replace("ù", "u")
|
|
51
|
+
.replace("ç", "c")
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
m = re.search(r"(\d{1,2})\s+([a-z]+)\s+(\d{4})", s)
|
|
55
|
+
if not m:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
day, month_name, year = int(m.group(1)), m.group(2), int(m.group(3))
|
|
59
|
+
month = MONTHS_FR.get(month_name)
|
|
60
|
+
if not month:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return datetime(year, month, day).strftime("%Y-%m-%d")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_total_ttc(text: str) -> float | None:
|
|
67
|
+
block = re.search(
|
|
68
|
+
r"Total\s*€\s*HT.*?Total\s*€\s*TTC(.*)", text, flags=re.IGNORECASE | re.DOTALL
|
|
69
|
+
)
|
|
70
|
+
if not block:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
zone = block.group(1)
|
|
74
|
+
amounts = re.findall(r"(\d{1,3}(?:[\s\u00A0\u202F]\d{3})*,\d{2})", zone)
|
|
75
|
+
if len(amounts) < 3:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
return normalize(amounts[2])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_invoice_fields(pdf_path: str) -> dict:
|
|
82
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
83
|
+
full_text = "\n".join((p.extract_text() or "") for p in pdf.pages)
|
|
84
|
+
|
|
85
|
+
ref_match = re.search(r"Référence\s*:\s*([^\r\n]+)", full_text, flags=re.IGNORECASE)
|
|
86
|
+
reference_line = ref_match.group(1).strip() if ref_match else None
|
|
87
|
+
reference = None
|
|
88
|
+
if reference_line:
|
|
89
|
+
reference = re.split(
|
|
90
|
+
r"\s+\ble\s+\d{1,2}\s+[A-Za-zÀ-ÿ]+\s+\d{4}\b", reference_line, maxsplit=1
|
|
91
|
+
)[0].strip()
|
|
92
|
+
|
|
93
|
+
date_match = re.search(
|
|
94
|
+
r"\ble\s+(\d{1,2}\s+[A-Za-zÀ-ÿ]+\s+\d{4})", full_text, flags=re.IGNORECASE
|
|
95
|
+
)
|
|
96
|
+
date_iso = parse_french_date(date_match.group(1)) if date_match else None
|
|
97
|
+
|
|
98
|
+
total_ttc = extract_total_ttc(full_text)
|
|
99
|
+
|
|
100
|
+
return {"Client": reference, "date": date_iso, "total_ttc": total_ttc}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv=None) -> int: # pragma: no cover
|
|
104
|
+
import argparse
|
|
105
|
+
|
|
106
|
+
ap = argparse.ArgumentParser(prog="equipebaie-parse")
|
|
107
|
+
ap.add_argument("pdf", help="Chemin vers la facture PDF")
|
|
108
|
+
args = ap.parse_args(argv)
|
|
109
|
+
|
|
110
|
+
print(extract_invoice_fields(args.pdf))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__": # pragma: no cover
|
|
115
|
+
raise SystemExit(main())
|
ebia/xls_generator.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from openpyxl import Workbook
|
|
10
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
11
|
+
from openpyxl.utils import get_column_letter
|
|
12
|
+
|
|
13
|
+
# En-têtes
|
|
14
|
+
HEADERS = [
|
|
15
|
+
"Statut",
|
|
16
|
+
"Jour",
|
|
17
|
+
"Pièce",
|
|
18
|
+
"Document",
|
|
19
|
+
"Compte général",
|
|
20
|
+
"Compte auxiliaire",
|
|
21
|
+
"Libellé",
|
|
22
|
+
"Débit",
|
|
23
|
+
"Crédit",
|
|
24
|
+
"Date de l'échéance",
|
|
25
|
+
"Documents associés",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Invoice:
|
|
31
|
+
client: str
|
|
32
|
+
date_iso: str # "YYYY-MM-DD"
|
|
33
|
+
total_ttc: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_iso_date(s: str) -> date:
|
|
37
|
+
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _money(x: float) -> float:
|
|
41
|
+
return round(x + 1e-12, 2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _zfill_int(n: int, width: int) -> str:
|
|
45
|
+
return str(n).zfill(width)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def invoice_to_rows(
|
|
49
|
+
inv: Invoice,
|
|
50
|
+
*,
|
|
51
|
+
piece: str,
|
|
52
|
+
document: str,
|
|
53
|
+
statut: str = "",
|
|
54
|
+
tva_rate: float = 0.20,
|
|
55
|
+
) -> list[list[Any]]:
|
|
56
|
+
d = _parse_iso_date(inv.date_iso) # date
|
|
57
|
+
jour_dt = datetime.combine(d, time.min) # datetime (00:00:00)
|
|
58
|
+
|
|
59
|
+
ttc = _money(inv.total_ttc)
|
|
60
|
+
ht = _money(ttc / (1 + tva_rate))
|
|
61
|
+
tva = _money(ttc - ht)
|
|
62
|
+
|
|
63
|
+
# Colonnes: Statut, Jour, Pièce, Document, CG, CA, Libellé, Débit, Crédit, Date éch., Docs assoc.
|
|
64
|
+
return [
|
|
65
|
+
[statut, jour_dt, piece, document, "411", "", inv.client, ttc, "", d, ""],
|
|
66
|
+
[statut, jour_dt, piece, document, "44571", "", inv.client, "", tva, "", ""],
|
|
67
|
+
[statut, jour_dt, piece, document, "701", "", inv.client, "", ht, "", ""],
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def style_as_table(ws) -> None:
|
|
72
|
+
thin = Side(style="thin")
|
|
73
|
+
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
74
|
+
|
|
75
|
+
header_fill = PatternFill("solid", fgColor="E6E6E6")
|
|
76
|
+
header_font = Font(bold=True)
|
|
77
|
+
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
78
|
+
|
|
79
|
+
align_left = Alignment(horizontal="left", vertical="center")
|
|
80
|
+
align_center = Alignment(horizontal="center", vertical="center")
|
|
81
|
+
align_right = Alignment(horizontal="right", vertical="center")
|
|
82
|
+
|
|
83
|
+
max_row = ws.max_row
|
|
84
|
+
max_col = ws.max_column
|
|
85
|
+
|
|
86
|
+
# Header style (ligne 1)
|
|
87
|
+
for c in range(1, max_col + 1):
|
|
88
|
+
cell = ws.cell(row=1, column=c)
|
|
89
|
+
cell.fill = header_fill
|
|
90
|
+
cell.font = header_font
|
|
91
|
+
cell.alignment = header_align
|
|
92
|
+
cell.border = border
|
|
93
|
+
|
|
94
|
+
# Body: bordures + alignement par défaut
|
|
95
|
+
for r in range(2, max_row + 1):
|
|
96
|
+
for c in range(1, max_col + 1):
|
|
97
|
+
cell = ws.cell(row=r, column=c)
|
|
98
|
+
cell.border = border
|
|
99
|
+
cell.alignment = align_left
|
|
100
|
+
|
|
101
|
+
# Alignements par colonne (A..K)
|
|
102
|
+
center_cols = ["A", "B", "C", "D", "E", "F", "J", "K"]
|
|
103
|
+
right_cols = ["H", "I"]
|
|
104
|
+
left_cols = ["G"]
|
|
105
|
+
|
|
106
|
+
for col in center_cols:
|
|
107
|
+
for r in range(2, max_row + 1):
|
|
108
|
+
ws[f"{col}{r}"].alignment = align_center
|
|
109
|
+
|
|
110
|
+
for col in right_cols:
|
|
111
|
+
for r in range(2, max_row + 1):
|
|
112
|
+
ws[f"{col}{r}"].alignment = align_right
|
|
113
|
+
|
|
114
|
+
for col in left_cols:
|
|
115
|
+
for r in range(2, max_row + 1):
|
|
116
|
+
ws[f"{col}{r}"].alignment = align_left
|
|
117
|
+
|
|
118
|
+
# Formats
|
|
119
|
+
# Jour (B) : datetime
|
|
120
|
+
# Date échéance (J) : date
|
|
121
|
+
# Montants (H/I)
|
|
122
|
+
for r in range(2, max_row + 1):
|
|
123
|
+
ws[f"B{r}"].number_format = "dd/mm/yyyy hh:mm:ss"
|
|
124
|
+
ws[f"J{r}"].number_format = "dd/mm/yyyy"
|
|
125
|
+
ws[f"H{r}"].number_format = "#,##0.00"
|
|
126
|
+
ws[f"I{r}"].number_format = "#,##0.00"
|
|
127
|
+
|
|
128
|
+
# Largeurs (ajuste si besoin)
|
|
129
|
+
widths = {
|
|
130
|
+
"A": 10,
|
|
131
|
+
"B": 20,
|
|
132
|
+
"C": 8,
|
|
133
|
+
"D": 10,
|
|
134
|
+
"E": 14,
|
|
135
|
+
"F": 16,
|
|
136
|
+
"G": 40,
|
|
137
|
+
"H": 12,
|
|
138
|
+
"I": 12,
|
|
139
|
+
"J": 16,
|
|
140
|
+
"K": 20,
|
|
141
|
+
}
|
|
142
|
+
for col, w in widths.items():
|
|
143
|
+
ws.column_dimensions[col].width = w
|
|
144
|
+
|
|
145
|
+
# Hauteur header
|
|
146
|
+
ws.row_dimensions[1].height = 20
|
|
147
|
+
|
|
148
|
+
# Freeze + Filter
|
|
149
|
+
ws.freeze_panes = "A2"
|
|
150
|
+
ws.auto_filter.ref = f"A1:{get_column_letter(max_col)}{max_row}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_xlsx_many(
|
|
154
|
+
rows_all: list[list[Any]],
|
|
155
|
+
output_path: str,
|
|
156
|
+
*,
|
|
157
|
+
sheet_name: str = "EBIA",
|
|
158
|
+
include_headers: bool = True,
|
|
159
|
+
) -> None:
|
|
160
|
+
wb = Workbook()
|
|
161
|
+
ws = wb.active
|
|
162
|
+
ws.title = sheet_name
|
|
163
|
+
|
|
164
|
+
# Header
|
|
165
|
+
if include_headers:
|
|
166
|
+
ws.append(HEADERS)
|
|
167
|
+
|
|
168
|
+
# Data
|
|
169
|
+
for r in rows_all:
|
|
170
|
+
ws.append(r)
|
|
171
|
+
|
|
172
|
+
# Style tableau (APRÈS tout)
|
|
173
|
+
if include_headers and ws.max_row >= 1 and ws.max_column >= 1:
|
|
174
|
+
style_as_table(ws)
|
|
175
|
+
|
|
176
|
+
wb.save(output_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def generate_xlsx_single(
|
|
180
|
+
invoice_dict: dict,
|
|
181
|
+
output_path: str,
|
|
182
|
+
*,
|
|
183
|
+
piece: str,
|
|
184
|
+
document: str,
|
|
185
|
+
tva_rate: float = 0.20,
|
|
186
|
+
include_headers: bool = True,
|
|
187
|
+
) -> None:
|
|
188
|
+
client = (invoice_dict.get("Client") or "").strip()
|
|
189
|
+
date_iso = (invoice_dict.get("date") or "").strip()
|
|
190
|
+
total_ttc = invoice_dict.get("total_ttc")
|
|
191
|
+
|
|
192
|
+
if not client:
|
|
193
|
+
raise ValueError("Missing 'Client'")
|
|
194
|
+
if not date_iso:
|
|
195
|
+
raise ValueError("Missing 'date' (expected YYYY-MM-DD)")
|
|
196
|
+
if total_ttc is None:
|
|
197
|
+
raise ValueError("Missing 'total_ttc'")
|
|
198
|
+
|
|
199
|
+
inv = Invoice(client=client, date_iso=date_iso, total_ttc=float(total_ttc))
|
|
200
|
+
rows = invoice_to_rows(inv, piece=piece, document=document, statut="", tva_rate=tva_rate)
|
|
201
|
+
write_xlsx_many(rows, output_path, include_headers=include_headers)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def generate_xlsx_by_month_day(
|
|
205
|
+
invoice_dicts: list[dict],
|
|
206
|
+
output_dir: str,
|
|
207
|
+
*,
|
|
208
|
+
start_piece: int = 1,
|
|
209
|
+
start_document: int = 1,
|
|
210
|
+
piece_width: int = 4,
|
|
211
|
+
document_width: int = 5,
|
|
212
|
+
tva_rate: float = 0.20,
|
|
213
|
+
include_headers: bool = True,
|
|
214
|
+
) -> list[str]:
|
|
215
|
+
"""Generate one .xlsx per month, with one sheet per day inside each workbook.
|
|
216
|
+
|
|
217
|
+
Invoices are sorted by date before numbering so piece/document counters
|
|
218
|
+
are assigned in chronological order across all months.
|
|
219
|
+
|
|
220
|
+
Returns the list of generated file paths.
|
|
221
|
+
"""
|
|
222
|
+
# --- 1. Parse & validate all invoice dicts ---
|
|
223
|
+
valid: list[tuple[Invoice, date]] = []
|
|
224
|
+
for inv_dict in invoice_dicts:
|
|
225
|
+
client = (inv_dict.get("Client") or "").strip()
|
|
226
|
+
date_iso = (inv_dict.get("date") or "").strip()
|
|
227
|
+
total_ttc = inv_dict.get("total_ttc")
|
|
228
|
+
if not client or not date_iso or total_ttc is None:
|
|
229
|
+
continue
|
|
230
|
+
inv = Invoice(client=client, date_iso=date_iso, total_ttc=float(total_ttc))
|
|
231
|
+
valid.append((inv, _parse_iso_date(date_iso)))
|
|
232
|
+
|
|
233
|
+
# Sort chronologically so piece/document numbering follows invoice date order
|
|
234
|
+
valid.sort(key=lambda t: t[1])
|
|
235
|
+
|
|
236
|
+
# --- 2. Assign piece / document numbers (global, sequential) ---
|
|
237
|
+
numbered: list[tuple[Invoice, date, str, str]] = []
|
|
238
|
+
piece_n = start_piece
|
|
239
|
+
doc_n = start_document
|
|
240
|
+
for inv, d in valid:
|
|
241
|
+
piece = _zfill_int(piece_n, piece_width)
|
|
242
|
+
document = _zfill_int(doc_n, document_width)
|
|
243
|
+
numbered.append((inv, d, piece, document))
|
|
244
|
+
piece_n += 1
|
|
245
|
+
doc_n += 1
|
|
246
|
+
|
|
247
|
+
# --- 3. Group by (year, month), then by day ---
|
|
248
|
+
by_month: dict[tuple[int, int], dict[int, list[tuple[Invoice, str, str]]]] = defaultdict(
|
|
249
|
+
lambda: defaultdict(list)
|
|
250
|
+
)
|
|
251
|
+
for inv, d, piece, document in numbered:
|
|
252
|
+
by_month[(d.year, d.month)][d.day].append((inv, piece, document))
|
|
253
|
+
|
|
254
|
+
# --- 4. Write one workbook per month ---
|
|
255
|
+
out_dir = Path(output_dir)
|
|
256
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
|
|
258
|
+
generated: list[str] = []
|
|
259
|
+
|
|
260
|
+
for (year, month), days in sorted(by_month.items()):
|
|
261
|
+
wb = Workbook()
|
|
262
|
+
wb.remove(wb.active) # drop the default empty sheet
|
|
263
|
+
|
|
264
|
+
for day in sorted(days.keys()):
|
|
265
|
+
sheet_name = f"{day:02d}"
|
|
266
|
+
ws = wb.create_sheet(title=sheet_name)
|
|
267
|
+
|
|
268
|
+
if include_headers:
|
|
269
|
+
ws.append(HEADERS)
|
|
270
|
+
|
|
271
|
+
for inv, piece, document in days[day]:
|
|
272
|
+
for row in invoice_to_rows(inv, piece=piece, document=document, tva_rate=tva_rate):
|
|
273
|
+
ws.append(row)
|
|
274
|
+
|
|
275
|
+
if include_headers and ws.max_row >= 1 and ws.max_column >= 1:
|
|
276
|
+
style_as_table(ws)
|
|
277
|
+
|
|
278
|
+
filepath = str(out_dir / f"{year}-{month:02d}.xlsx")
|
|
279
|
+
wb.save(filepath)
|
|
280
|
+
generated.append(filepath)
|
|
281
|
+
|
|
282
|
+
return generated
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ebia
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: EquipeBaie Invoice Automation
|
|
5
|
+
Author: AAH
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pdfplumber
|
|
9
|
+
Requires-Dist: openpyxl
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
14
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
15
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# EquipeBaie_Freelance-project
|
|
18
|
+
Invoicing software that generates invoices and automatically syncs them to accounting tool.
|
|
19
|
+
# EquipeBaie Invoice Automation (equipebaie-tools)
|
|
20
|
+
|
|
21
|
+
This project aims to build a Python software tool that:
|
|
22
|
+
|
|
23
|
+
1. Detects new invoices (PDF) in a folder
|
|
24
|
+
2. Parses invoices and extracts required fields (EquipeBaie requirements)
|
|
25
|
+
3. Processes/validates the extracted data
|
|
26
|
+
4. Generates Excel reports (`.xlsx`) classified **by month** and **by week**
|
|
27
|
+
|
|
28
|
+
At the moment, the package contains the first module: **PDF parser**.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Current Status
|
|
33
|
+
|
|
34
|
+
**Step 1 (implemented):** PDF parsing module
|
|
35
|
+
**Next steps:** Excel generation + folder watcher (auto-detect new invoices) + pipeline orchestration
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Project Structure (wheel-ready)
|
|
40
|
+
|
|
41
|
+
equipebaie_tools/
|
|
42
|
+
├─ pyproject.toml
|
|
43
|
+
├─ README.md
|
|
44
|
+
├─ src/
|
|
45
|
+
│ └─ equipebaie_tools/
|
|
46
|
+
│ ├─ __init__.py
|
|
47
|
+
│ ├─ parser.py
|
|
48
|
+
│ └─ cli.py
|
|
49
|
+
└─ tests/
|
|
50
|
+
└─ test_import.py
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
- `src/equipebaie_tools/` is the installable Python package
|
|
55
|
+
- `parser.py` exposes the main function: `extract_invoice_fields(pdf_path)`
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Python >= 3.9
|
|
62
|
+
- `pip` up to date
|
|
63
|
+
|
|
64
|
+
Recommended: use a virtual environment.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Installation (Development / Editable)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
|
|
73
|
+
## 1) Create a virtual environment
|
|
74
|
+
**Linux/macOS**
|
|
75
|
+
|
|
76
|
+
python -m venv .venv
|
|
77
|
+
source .venv/bin/activate
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Windows (PowerShell)
|
|
81
|
+
python -m venv .venv
|
|
82
|
+
.\.venv\Scripts\Activate.ps1
|
|
83
|
+
|
|
84
|
+
## 2) Install the package in editable mode
|
|
85
|
+
|
|
86
|
+
pip install -U pip
|
|
87
|
+
pip install -e .
|
|
88
|
+
|
|
89
|
+
## Quickstart (Build, Install, Run)
|
|
90
|
+
|
|
91
|
+
1) Clone the repository
|
|
92
|
+
|
|
93
|
+
git clone https://github.com/Alamajdoub9/EquipeBaie_Freelance-project.git
|
|
94
|
+
cd EquipeBaie_Freelance-project
|
|
95
|
+
|
|
96
|
+
2) Create and activate a virtual environment
|
|
97
|
+
Linux/macOS
|
|
98
|
+
|
|
99
|
+
python3 -m venv .venv
|
|
100
|
+
source .venv/bin/activate
|
|
101
|
+
Windows (PowerShell)
|
|
102
|
+
|
|
103
|
+
python -m venv .venv
|
|
104
|
+
.\.venv\Scripts\Activate.ps1
|
|
105
|
+
|
|
106
|
+
3) Install build tools and project dependencies
|
|
107
|
+
python -m pip install --upgrade pip
|
|
108
|
+
|
|
109
|
+
python -m pip install build wheel setuptools
|
|
110
|
+
|
|
111
|
+
When you install the wheel (next steps), dependencies are installed automatically.
|
|
112
|
+
|
|
113
|
+
4) Build the wheel (.whl)
|
|
114
|
+
Run this command from the project root (where pyproject.toml exists):
|
|
115
|
+
|
|
116
|
+
python -m build -w
|
|
117
|
+
After a successful build, you should have a wheel in:
|
|
118
|
+
ls -lh dist/
|
|
119
|
+
|
|
120
|
+
5) Install the wheel
|
|
121
|
+
pip install --force-reinstall dist/*.whl
|
|
122
|
+
|
|
123
|
+
Solution immédiate (offline)
|
|
124
|
+
|
|
125
|
+
pip install --force-reinstall --no-deps dist/ebia-0.1.0-py3-none-any.whl
|
|
126
|
+
|
|
127
|
+
6) Run the CLI
|
|
128
|
+
Parse a PDF invoice and print extracted fields:
|
|
129
|
+
|
|
130
|
+
ebia --path facture.pdf
|
|
131
|
+
|
|
132
|
+
## Generate Excel (XLSX)
|
|
133
|
+
|
|
134
|
+
The CLI `ebia` can generate an Excel file from:
|
|
135
|
+
- a **single PDF invoice**, or
|
|
136
|
+
- a **folder** containing multiple PDF invoices (3 rows per invoice, appended one after another).
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
1) Single PDF → one Excel file
|
|
141
|
+
ebia --path "./invoices/facture.pdf" --out "./output/result.xlsx" --piece 0000 --document 00000
|
|
142
|
+
|
|
143
|
+
2) Folder of PDFs → one consolidated Excel file (auto-increment piece/document)
|
|
144
|
+
|
|
145
|
+
This mode reads all *.pdf files in the folder, parses each invoice, and appends 3 rows per invoice into the same Excel sheet.
|
|
146
|
+
|
|
147
|
+
ebia --path "./invoices" --out "./output/global.xlsx" --start-piece 1 --start-document 1
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ebia/__init__.py,sha256=qnXyW6dMupNrvl1D0KsnAy3aUhjTfCv_b5AADlaXUxo,103
|
|
2
|
+
ebia/cli.py,sha256=ekBmF8OuZ54QBJHNQBaJSwhyAInRXE5zIbHU2SIoMx4,3914
|
|
3
|
+
ebia/parser.py,sha256=e3wwLezXk1iffc60Ds3NUw3BAHPE0HgcgveY-z4EVS4,3051
|
|
4
|
+
ebia/xls_generator.py,sha256=MI1BbQDj7OEQqm2UfUpCHOsEOpG6O6n0bdVpCTKKyjA,8169
|
|
5
|
+
ebia-0.1.4.dist-info/METADATA,sha256=TCnF8HsFQOURUq-2j55jRcVJb9nHoV0uMwY00af5lB0,3618
|
|
6
|
+
ebia-0.1.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
ebia-0.1.4.dist-info/entry_points.txt,sha256=5jzavokGBKkGnP2rJMEFWEb3YCxQn0Y8nBBh4v9TFtY,39
|
|
8
|
+
ebia-0.1.4.dist-info/top_level.txt,sha256=yg2ZFbd1qylefB1j4xl022tAfrot0s5cSyBF2UrVIeQ,5
|
|
9
|
+
ebia-0.1.4.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ebia
|