← Files VeraARCHIVED FILE
modules/client-file-preparation/scripts/scan_folder.py
24.9 KB · Oct 2, 2026 · 00:29 UTC
from __future__ import annotations
import csv
import hashlib
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable, Sequence
__all__ = [
"CATEGORY_730",
"CATEGORY_AVVISI",
"CATEGORY_CH_BANK_TAX",
"CATEGORY_CH_GE_TAX",
"CATEGORY_CH_SALARY_CERTIFICATE",
"CATEGORY_CH_TAX_ASSESSMENT",
"CATEGORY_CH_TAX_RETURN",
"CATEGORY_CH_ZH_TAX",
"CATEGORY_CONTRATTI",
"CATEGORY_CU",
"CATEGORY_F24",
"CATEGORY_FATTURE_XML",
"CATEGORY_MUTUO",
"CATEGORY_NON_CLASSIFICATI",
"CATEGORY_REDDITI_PF",
"CATEGORY_RICEVUTE_SANITARIE",
"CATEGORY_UK_BANK_TAX",
"CATEGORY_UK_HMRC_NOTICE",
"CATEGORY_UK_PAYSLIP",
"CATEGORY_UK_SELF_ASSESSMENT",
"CATEGORY_UK_YEAR_END_PAYROLL",
"FileRecord",
"MAX_SOURCE_ENTRIES",
"MAX_SOURCE_FILE_BYTES",
"MAX_SOURCE_FILES",
"MAX_SOURCE_TOTAL_BYTES",
"classify_file",
"extract_years",
"scan_folder",
"verify_source_snapshot",
"write_index_markdown",
"write_inventory_csv",
]
MAX_SOURCE_ENTRIES = 20_000
MAX_SOURCE_FILES = 5_000
MAX_SOURCE_FILE_BYTES = 256 * 1024 * 1024
MAX_SOURCE_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
CATEGORY_CU = "CU"
CATEGORY_730 = "730 / precompilata"
CATEGORY_REDDITI_PF = "Redditi PF"
CATEGORY_F24 = "F24"
CATEGORY_FATTURE_XML = "fatture elettroniche XML"
CATEGORY_RICEVUTE_SANITARIE = "ricevute sanitarie"
CATEGORY_MUTUO = "mutuo"
CATEGORY_AFFITTO = "affitto / locazione"
CATEGORY_ASSICURAZIONI = "assicurazioni"
CATEGORY_PREVIDENZA = "previdenza"
CATEGORY_AVVISI = "avvisi / comunicazioni"
CATEGORY_CONTRATTI = "contratti"
CATEGORY_CH_GE_TAX = "Geneva tax documents"
CATEGORY_CH_ZH_TAX = "Zurich tax documents"
CATEGORY_CH_TAX_RETURN = "CH tax return / déclaration fiscale / Steuererklärung"
CATEGORY_CH_TAX_ASSESSMENT = "CH tax assessment / taxation"
CATEGORY_CH_SALARY_CERTIFICATE = "CH salary certificate / Lohnausweis"
CATEGORY_CH_BANK_TAX = "CH bank and withholding tax certificates"
CATEGORY_UK_YEAR_END_PAYROLL = "UK P60 / P45 / P11D"
CATEGORY_UK_PAYSLIP = "UK payslip"
CATEGORY_UK_SELF_ASSESSMENT = "UK Self Assessment"
CATEGORY_UK_HMRC_NOTICE = "UK HMRC notices"
CATEGORY_UK_BANK_TAX = "UK bank and investment tax certificates"
CATEGORY_NON_CLASSIFICATI = "documenti non classificati"
YEAR_RE = re.compile(r"(?<!\d)(20[0-4]\d)(?!\d)")
@dataclass(frozen=True)
class FileRecord:
"""One document discovered in a customer folder."""
relative_path: str
file_name: str
extension: str
size_bytes: int
modified_iso: str
sha256: str
category: str
confidence: str
years: tuple[int, ...]
notes: tuple[str, ...]
def as_row(self) -> dict[str, str | int]:
"""Return a CSV-friendly representation."""
return {
"relative_path": self.relative_path,
"file_name": self.file_name,
"extension": self.extension,
"size_bytes": self.size_bytes,
"modified_iso": self.modified_iso,
"sha256": self.sha256,
"category": self.category,
"category_status": "candidate",
"category_basis": "lexical_hint",
"confidence": self.confidence,
"years": ";".join(str(year) for year in self.years),
"notes": " | ".join(self.notes),
}
def normalize_text(value: str) -> str:
"""Normalize text for filename heuristics."""
without_accents = unicodedata.normalize("NFKD", value).encode("ascii", "ignore")
normalized = without_accents.decode("ascii").lower()
return re.sub(r"[^a-z0-9]+", " ", normalized).strip()
def extract_years(value: str) -> tuple[int, ...]:
"""Extract plausible fiscal years from a path or filename."""
return tuple(sorted({int(match) for match in YEAR_RE.findall(value)}))
def _matches(text: str, patterns: Sequence[str]) -> bool:
return any(re.search(pattern, text) for pattern in patterns)
def _looks_like_fatturapa_xml(path: Path) -> bool:
"""Recognize the FatturaElettronica root in a bounded local prefix."""
if path.is_symlink() or not path.is_file():
return False
try:
with path.open("rb") as handle:
prefix = handle.read(128 * 1024)
except OSError:
return False
return bool(
re.search(
rb"<(?:[A-Za-z_][A-Za-z0-9_.-]*:)?FatturaElettronica(?:\s|>)",
prefix,
flags=re.IGNORECASE,
)
)
def _sha256_regular_file(path: Path) -> str:
"""Hash one regular source file without loading it into memory."""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def classify_file(
path: Path,
root: Path,
target_year: int | None = None,
*,
jurisdiction: str = "italy",
) -> tuple[str, str, tuple[str, ...]]:
"""Classify a file by path, extension, and conservative filename rules."""
relative = path.relative_to(root)
searchable = normalize_text(f"{relative.parent} {path.stem} {path.suffix}")
extension = path.suffix.lower()
notes: list[str] = []
if (
extension == ".xml"
and jurisdiction in {"italy", "mixed"}
and _looks_like_fatturapa_xml(path)
):
category = CATEGORY_FATTURE_XML
confidence = "media"
elif _matches(
searchable,
[
r"\bgeneve\b",
r"\bgeneva\b",
r"\bgenf\b",
r"\bafc\b",
r"administration fiscale cantonale",
r"etat de geneve",
r"\bge\b.*\bimpot",
],
):
category = CATEGORY_CH_GE_TAX
confidence = "media"
elif _matches(
searchable,
[
r"\bzurich\b",
r"\bzuerich\b",
r"\bzh\b.*\bsteuer",
r"kantonales steueramt",
r"steueramt zurich",
r"steueramt zuerich",
],
):
category = CATEGORY_CH_ZH_TAX
confidence = "media"
elif _matches(
searchable,
[
r"certificat de salaire",
r"certificato di salario",
r"\blohnausweis\b",
r"salary certificate",
],
):
category = CATEGORY_CH_SALARY_CERTIFICATE
confidence = "alta"
elif _matches(
searchable,
[
r"declaration d impot",
r"declaration fiscale",
r"declaration tax",
r"\bsteuererklarung\b",
r"\bsteuererkl[aä]rung\b",
r"dichiarazione fiscale",
],
):
category = CATEGORY_CH_TAX_RETURN
confidence = "media"
elif _matches(
searchable,
[
r"avis de taxation",
r"bordereau",
r"\bveranlagung",
r"einschatzungsentscheid",
r"\btax assessment\b",
],
):
category = CATEGORY_CH_TAX_ASSESSMENT
confidence = "media"
elif _matches(
searchable,
[
r"attestation fiscale",
r"releve fiscal",
r"releve de portefeuille",
r"steuerbescheinigung",
r"\bzinsausweis\b",
r"verrechnungssteuer",
],
):
category = CATEGORY_CH_BANK_TAX
confidence = "media"
elif _matches(searchable, [r"\bp60\b", r"\bp45\b", r"\bp11d\b"]):
category = CATEGORY_UK_YEAR_END_PAYROLL
confidence = "alta"
elif _matches(searchable, [r"\bpayslip\b", r"pay slip", r"payroll slip"]):
category = CATEGORY_UK_PAYSLIP
confidence = "media"
elif _matches(
searchable,
[
r"self assessment",
r"\bsa100\b",
r"\bsa302\b",
r"\butr\b",
r"\buk tax return\b",
],
):
category = CATEGORY_UK_SELF_ASSESSMENT
confidence = "media"
elif _matches(
searchable,
[
r"\bhmrc\b",
r"paye coding notice",
r"tax code notice",
r"simple assessment",
r"notice to file",
],
):
category = CATEGORY_UK_HMRC_NOTICE
confidence = "media"
elif _matches(
searchable,
[
r"interest certificate",
r"dividend voucher",
r"consolidated tax voucher",
r"tax certificate",
],
):
category = CATEGORY_UK_BANK_TAX
confidence = "media"
elif _matches(searchable, [r"\bcu\b", r"certificazione unica", r"cud"]):
category = CATEGORY_CU
confidence = "alta"
elif _matches(searchable, [r"\b730\b", r"precompilata"]):
category = CATEGORY_730
confidence = "alta"
elif _matches(searchable, [r"redditi", r"\bpf\b", r"unico"]):
category = CATEGORY_REDDITI_PF
confidence = "media"
elif _matches(searchable, [r"\bf24\b", r"delega"]):
category = CATEGORY_F24
confidence = "alta"
elif _matches(searchable, [r"sanitar", r"medic", r"farmac", r"scontrin"]):
category = CATEGORY_RICEVUTE_SANITARIE
confidence = "media"
elif _matches(searchable, [r"mutuo", r"interessi passivi", r"interess"]):
category = CATEGORY_MUTUO
confidence = "media"
elif _matches(searchable, [r"affitto", r"locazion", r"canone"]):
category = CATEGORY_AFFITTO
confidence = "media"
elif _matches(searchable, [r"assicuraz", r"polizza"]):
category = CATEGORY_ASSICURAZIONI
confidence = "media"
elif _matches(searchable, [r"previd", r"inps", r"cassa"]):
category = CATEGORY_PREVIDENZA
confidence = "media"
elif _matches(searchable, [r"avviso", r"agenzia", r"comunicaz", r"cartella"]):
category = CATEGORY_AVVISI
confidence = "media"
elif _matches(searchable, [r"contratto", r"scrittura privata"]):
category = CATEGORY_CONTRATTI
confidence = "media"
else:
category = CATEGORY_NON_CLASSIFICATI
confidence = "bassa"
notes.append("classificazione non certa")
years = extract_years(str(relative))
if target_year is not None and years and target_year not in years:
notes.append(f"anno non coerente con target {target_year}")
if extension in {".jpg", ".jpeg", ".png", ".heic"}:
notes.append("immagine: possibile ricevuta o documento scansionato")
if extension == ".xml" and category != CATEGORY_FATTURE_XML:
notes.append("XML generico: struttura FatturaPA non individuata")
return category, confidence, tuple(notes)
def _should_skip(path: Path, root: Path, output_dir: Path | None) -> bool:
if any(part in {".git", "__pycache__", ".DS_Store"} for part in path.parts):
return True
if output_dir is not None:
try:
path.relative_to(output_dir)
except ValueError:
return False
return True
return False
def scan_folder(
root: Path | str,
target_year: int | None = None,
output_dir: Path | str | None = None,
*,
jurisdiction: str = "italy",
language: str = "it",
) -> list[FileRecord]:
"""Scan a customer folder and return classified file records."""
root_path = Path(root).expanduser().resolve()
if not root_path.exists() or not root_path.is_dir():
raise NotADirectoryError(f"Cartella non valida: {root_path}")
output_path = Path(output_dir).expanduser().resolve() if output_dir else None
records: list[FileRecord] = []
inspected_entry_count = 0
regular_source_bytes = 0
error_copy = {
"it": {
"entries": "La cartella cliente supera il limite di elementi ispezionabili",
"files": "La cartella cliente supera il limite di file",
"file_size": "Il file sorgente supera il limite di dimensione",
"total_size": "I file sorgente superano il limite complessivo di dimensione",
},
"en": {
"entries": "The client folder exceeds the inspected-entry limit",
"files": "The client folder exceeds the file-count limit",
"file_size": "The source file exceeds the per-file size limit",
"total_size": "The source files exceed the total-size limit",
},
"fr": {
"entries": "Le dossier client dépasse la limite d’éléments inspectés",
"files": "Le dossier client dépasse la limite de fichiers",
"file_size": "Le fichier source dépasse la limite de taille par fichier",
"total_size": "Les fichiers source dépassent la limite de taille totale",
},
"de": {
"entries": "Der Mandantenordner überschreitet die Grenze der geprüften Einträge",
"files": "Der Mandantenordner überschreitet die Dateianzahlgrenze",
"file_size": "Die Quelldatei überschreitet die Größenbegrenzung pro Datei",
"total_size": "Die Quelldateien überschreiten die Gesamtgrößenbegrenzung",
},
"es": {
"entries": "La carpeta del cliente supera el límite de elementos inspeccionables",
"files": "La carpeta del cliente supera el límite de archivos",
"file_size": "El archivo fuente supera el límite de tamaño por archivo",
"total_size": "Los archivos fuente superan el límite de tamaño total",
},
}.get(language)
if error_copy is None:
raise ValueError(f"Unsupported language: {language}")
for path in sorted(root_path.rglob("*")):
inspected_entry_count += 1
if inspected_entry_count > MAX_SOURCE_ENTRIES:
raise ValueError(f"{error_copy['entries']}: {MAX_SOURCE_ENTRIES}")
if _should_skip(path, root_path, output_path):
continue
relative = path.relative_to(root_path)
cursor = root_path
contains_symlink = False
for part in relative.parts:
cursor /= part
if cursor.is_symlink():
contains_symlink = True
break
if not contains_symlink and not path.is_file():
continue
category, confidence, notes = classify_file(
path,
root_path,
target_year,
jurisdiction=jurisdiction,
)
stat = path.lstat() if contains_symlink else path.stat()
if contains_symlink:
category = CATEGORY_NON_CLASSIFICATI
confidence = "bassa"
notes = (*notes, "collegamento simbolico non seguito")
if len(records) >= MAX_SOURCE_FILES:
raise ValueError(f"{error_copy['files']}: {MAX_SOURCE_FILES}")
if not contains_symlink:
if stat.st_size > MAX_SOURCE_FILE_BYTES:
raise ValueError(
f"{error_copy['file_size']}: {relative.as_posix()} "
f"({stat.st_size} > {MAX_SOURCE_FILE_BYTES} byte)"
)
regular_source_bytes += stat.st_size
if regular_source_bytes > MAX_SOURCE_TOTAL_BYTES:
raise ValueError(
f"{error_copy['total_size']}: "
f"{regular_source_bytes} > {MAX_SOURCE_TOTAL_BYTES} byte"
)
modified = datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0)
source_hash = "" if contains_symlink else _sha256_regular_file(path)
records.append(
FileRecord(
relative_path=relative.as_posix(),
file_name=path.name,
extension=path.suffix.lower(),
size_bytes=stat.st_size,
modified_iso=modified.isoformat(),
sha256=source_hash,
category=category,
confidence=confidence,
years=extract_years(relative.as_posix()),
notes=notes,
)
)
return records
def write_inventory_csv(records: Iterable[FileRecord], output_path: Path | str) -> Path:
"""Write a CSV inventory for the scanned folder."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
rows = [record.as_row() for record in records]
fieldnames = [
"relative_path",
"file_name",
"extension",
"size_bytes",
"modified_iso",
"sha256",
"category",
"category_status",
"category_basis",
"confidence",
"years",
"notes",
]
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return path
def verify_source_snapshot(records: Sequence[FileRecord], root: Path | str) -> None:
"""Fail if a source changed type or content after the initial scan."""
root_path = Path(root).expanduser().resolve()
for record in records:
source_path = root_path / record.relative_path
cursor = root_path
contains_symlink = False
for part in Path(record.relative_path).parts:
cursor /= part
if cursor.is_symlink():
contains_symlink = True
if not record.sha256:
break
raise RuntimeError(
f"Il file sorgente è diventato un link simbolico durante il run: {record.relative_path}"
)
if not record.sha256:
if not contains_symlink:
raise RuntimeError(
"Il collegamento simbolico sorgente è cambiato tipo durante "
f"il run: {record.relative_path}"
)
continue
if not source_path.is_file():
raise RuntimeError(
f"Il file sorgente non è più disponibile durante il run: {record.relative_path}"
)
if source_path.stat().st_size != record.size_bytes:
raise RuntimeError(
f"Il file sorgente è cambiato durante il run: {record.relative_path}"
)
if _sha256_regular_file(source_path) != record.sha256:
raise RuntimeError(
f"Il file sorgente è cambiato durante il run: {record.relative_path}"
)
def _category_counts(records: Sequence[FileRecord]) -> dict[str, int]:
counts: dict[str, int] = {}
for record in records:
counts[record.category] = counts.get(record.category, 0) + 1
return dict(sorted(counts.items(), key=lambda item: item[0].lower()))
def write_index_markdown(
records: Sequence[FileRecord],
output_path: Path | str,
root: Path | str,
target_year: int | None = None,
*,
language: str = "it",
) -> Path:
"""Write a readable markdown index for the customer folder."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
root_path = Path(root)
labels = {
"it": {
"title": "Indice fascicolo",
"folder": "Cartella",
"year": "Anno target",
"year_missing": "non indicato",
"files": "File analizzati",
"categories": "Categorie individuate",
"none": "Nessun file trovato.",
"detail": "Dettaglio file",
},
"en": {
"title": "Client file index",
"folder": "Local folder",
"year": "Target year",
"year_missing": "not specified",
"files": "Files reviewed",
"categories": "Categories identified",
"none": "No files found.",
"detail": "File details",
},
"fr": {
"title": "Index du dossier client",
"folder": "Dossier local",
"year": "Année cible",
"year_missing": "non indiquée",
"files": "Fichiers examinés",
"categories": "Catégories identifiées",
"none": "Aucun fichier trouvé.",
"detail": "Détail des fichiers",
},
"de": {
"title": "Index der Mandantenakte",
"folder": "Lokaler Ordner",
"year": "Zieljahr",
"year_missing": "nicht angegeben",
"files": "Geprüfte Dateien",
"categories": "Erkannte Kategorien",
"none": "Keine Dateien gefunden.",
"detail": "Dateidetails",
},
"es": {
"title": "Índice del expediente del cliente",
"folder": "Carpeta local",
"year": "Año objetivo",
"year_missing": "no especificado",
"files": "Archivos revisados",
"categories": "Categorías identificadas",
"none": "No se encontraron archivos.",
"detail": "Detalle de archivos",
},
}[language]
category_labels = {
"en": {
CATEGORY_FATTURE_XML: "electronic invoices (XML)",
CATEGORY_RICEVUTE_SANITARIE: "medical receipts",
CATEGORY_MUTUO: "mortgage",
CATEGORY_AVVISI: "notices / communications",
CATEGORY_CONTRATTI: "contracts",
CATEGORY_NON_CLASSIFICATI: "unclassified documents",
},
"fr": {
CATEGORY_FATTURE_XML: "factures électroniques XML",
CATEGORY_RICEVUTE_SANITARIE: "reçus médicaux",
CATEGORY_MUTUO: "prêt hypothécaire",
CATEGORY_AVVISI: "avis / communications",
CATEGORY_CONTRATTI: "contrats",
CATEGORY_NON_CLASSIFICATI: "documents non classés",
},
"de": {
CATEGORY_FATTURE_XML: "elektronische Rechnungen (XML)",
CATEGORY_RICEVUTE_SANITARIE: "Gesundheitsbelege",
CATEGORY_MUTUO: "Hypothek",
CATEGORY_AVVISI: "Bescheide / Mitteilungen",
CATEGORY_CONTRATTI: "Verträge",
CATEGORY_NON_CLASSIFICATI: "nicht klassifizierte Dokumente",
},
"es": {
CATEGORY_FATTURE_XML: "facturas electrónicas XML",
CATEGORY_RICEVUTE_SANITARIE: "justificantes médicos",
CATEGORY_MUTUO: "hipoteca",
CATEGORY_AVVISI: "avisos / comunicaciones",
CATEGORY_CONTRATTI: "contratos",
CATEGORY_NON_CLASSIFICATI: "documentos sin clasificar",
},
}.get(language, {})
year_text = str(target_year) if target_year is not None else labels["year_missing"]
counts = _category_counts(records)
lines: list[str] = [
f"# {labels['title']}",
"",
f"- {labels['folder']}: `{root_path}`",
f"- {labels['year']}: {year_text}",
f"- {labels['files']}: {len(records)}",
"",
f"## {labels['categories']}",
"",
]
if counts:
lines.extend(
f"- {category_labels.get(category, category)}: {count}"
for category, count in counts.items()
)
else:
lines.append(f"- {labels['none']}")
lines.extend(["", f"## {labels['detail']}", ""])
confidence_labels = {
"it": {"alta": "alta", "media": "media", "bassa": "bassa"},
"en": {"alta": "high", "media": "medium", "bassa": "low"},
"fr": {"alta": "élevée", "media": "moyenne", "bassa": "faible"},
"de": {"alta": "hoch", "media": "mittel", "bassa": "niedrig"},
"es": {"alta": "alta", "media": "media", "bassa": "baja"},
}[language]
for record in records:
localized_notes: list[str] = []
for note_value in record.notes:
if note_value == "classificazione non certa":
localized_notes.append(
{
"it": note_value,
"en": "classification uncertain",
"fr": "classification incertaine",
"de": "Klassifizierung unklar",
"es": "clasificación incierta",
}[language]
)
elif note_value.startswith("anno non coerente"):
localized_notes.append(
{
"it": note_value,
"en": f"year differs from target {target_year}",
"fr": f"année différente de la cible {target_year}",
"de": f"Jahr weicht vom Zieljahr {target_year} ab",
"es": f"el año difiere del objetivo {target_year}",
}[language]
)
elif note_value.startswith("immagine"):
localized_notes.append(
{
"it": note_value,
"en": "image: possible receipt or scanned document",
"fr": "image : reçu possible ou document numérisé",
"de": "Bild: möglicher Beleg oder gescanntes Dokument",
"es": "imagen: posible justificante o documento escaneado",
}[language]
)
else:
localized_notes.append(note_value)
note = f" — {', '.join(localized_notes)}" if localized_notes else ""
lines.append(
f"- `{record.relative_path}` — {category_labels.get(record.category, record.category)} "
f"({confidence_labels.get(record.confidence, record.confidence)}){note}"
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
SHA-256: b4e5bb6dd0347b9c66e1e21b3f21a7e1a84805f58008401857cc613e90043789