JEI_Import_Documents-App/scripts/rename_invoices.py
Mavis 922cd01dd4 chore: rename JEI invoice PDFs to invoice numbers + dedupe
- 22 files renamed to <invoice_no>.pdf (154923.pdf, 151778.pdf, etc.)
- 5 duplicate files moved to invoices/_non_jei/
- 7 files left as-is (multi-invoice PDFs or unscannable:
  1.Invoice_29.05.pdf, Invoice.pdf, JEI Drilling Invoices.pdf,
  JEI SA Invoice 147842.pdf, JEI SA Proforma 148931.pdf,
  newdoc10018920260413143327_001.pdf, Supplier Invoice.pdf,
  Supplier Invoices.pdf)
2026-09-18 17:55:12 +02:00

223 lines
7.6 KiB
Python

"""
Rename JEI invoice PDFs to their invoice number, dedupe duplicates.
For each PDF in invoices/ (not _non_jei):
1. Extract invoice number (from filename or PDF content)
2. Group files by invoice number
3. For each group: keep ONE file in invoices/, move others to _non_jei/
(prefer the file that has extractable text; break ties by larger size)
4. Rename the kept file to "<invoice_no>.pdf"
For multi-invoice PDFs (one PDF contains N invoices) and files where we
can't find an invoice number, leave the original filename.
"""
import os, re, shutil, sys, json, hashlib
from pathlib import Path
import pdfplumber
import fitz # PyMuPDF
ROOT = Path(__file__).resolve().parent.parent
INVOICES = ROOT / "invoices"
NON_JEI = INVOICES / "_non_jei"
OCR_DIR = ROOT / "data" / "review" / "ocr"
NON_JEI.mkdir(exist_ok=True)
def file_hash(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
def has_text(pdf_path: Path) -> bool:
"""True if pdfplumber finds any extractable text in the PDF."""
try:
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages[:2]:
if (page.extract_text() or "").strip():
return True
except Exception:
pass
return False
def extract_invoice_no_from_text(pdf_path: Path) -> str | None:
"""Look for 'Invoice No XXXXXX' or 'Quotation No XXXXXX' in first 2 pages."""
try:
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages[:2]:
t = (page.extract_text() or "").strip()
m = re.search(r"(?:Invoice|Quotation)\s*No\s*(\d{3,7})", t, re.IGNORECASE)
if m:
return m.group(1)
except Exception:
pass
# Try OCR text
ocr_file = OCR_DIR / pdf_path.stem.strip() / "page_001.txt"
if ocr_file.exists():
text = ocr_file.read_text(encoding="utf-8", errors="replace")
m = re.search(r"(?:Invoice|Quotation)\s*No\s*(\d{3,7})", text, re.IGNORECASE)
if m:
return m.group(1)
return None
def count_invoices_in_pdf(pdf_path: Path) -> int:
"""How many distinct invoice numbers are in the PDF? (for multi-invoice detection)"""
nums = set()
try:
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
t = (page.extract_text() or "")
for m in re.finditer(r"(?:Invoice|Quotation)\s*No\s*(\d{3,7})", t, re.IGNORECASE):
nums.add(m.group(1))
except Exception:
pass
if not nums:
# Try OCR
ocr_dir = OCR_DIR / pdf_path.stem.strip()
if ocr_dir.exists():
for page_file in sorted(ocr_dir.glob("page_*.txt")):
t = page_file.read_text(encoding="utf-8", errors="replace")
for m in re.finditer(r"(?:Invoice|Quotation)\s*No\s*(\d{3,7})", t, re.IGNORECASE):
nums.add(m.group(1))
return len(nums)
def extract_from_filename(name: str) -> str | None:
"""Try to pull a 5-7 digit invoice number out of the filename."""
base = name[:-4] if name.lower().endswith(".pdf") else name
# Order matters — try most-specific patterns first
patterns = [
r"Proforma\s+(\d{5,7})",
r"PROFORMA\s+(\d{5,7})",
r"Quote\s+(\d{5,7})",
r"Invoice\s*No\s+(\d{5,7})",
r"Invoice[_\s]+(\d{5,7})",
r"^(\d{5,7})\s+Supplier",
r"Supplier\s+Invoice\s+(\d{5,7})",
r"^(\d{5,7})\b",
]
for p in patterns:
m = re.search(p, base, re.IGNORECASE)
if m:
return m.group(1)
return None
def get_invoice_no(pdf_path: Path) -> tuple[str | None, int]:
"""Return (invoice_no, n_invoices_in_pdf). n_invoices > 1 means multi-invoice."""
fname = pdf_path.name
no = extract_from_filename(fname)
if no:
# Verify it's a single-invoice PDF
n = count_invoices_in_pdf(pdf_path)
if n > 1:
return no, n # might be first invoice no, but it's multi
return no, n
# Fallback: extract from PDF content
no = extract_invoice_no_from_text(pdf_path)
if no:
n = count_invoices_in_pdf(pdf_path)
return no, n
return None, 0
def main():
pdfs = sorted(p for p in INVOICES.glob("*.pdf"))
if not pdfs:
print("No PDFs in invoices/")
return
# First pass: hash each file to find exact duplicates
files_by_hash: dict[str, list[Path]] = {}
for p in pdfs:
h = file_hash(p)
files_by_hash.setdefault(h, []).append(p)
# Second pass: extract invoice numbers, group
by_invoice_no: dict[str, list[tuple[Path, int, bool]]] = {}
no_invoice: list[Path] = []
for p in pdfs:
no, n = get_invoice_no(p)
if no and n == 1:
by_invoice_no.setdefault(no, []).append((p, n, has_text(p)))
else:
no_invoice.append(p)
print(f"Found {len(by_invoice_no)} unique single-invoice numbers")
print(f"Found {len(no_invoice)} files with no/ambiguous invoice no (left as-is)")
# Plan: for each invoice_no group, pick the BEST file to keep in invoices/
plan_kept = {} # invoice_no -> Path
plan_move = [] # list of (src, reason) — files to move to _non_jei/
for no, files in by_invoice_no.items():
# Sort: text-extractable first, then largest size
files.sort(key=lambda x: (not x[2], -x[0].stat().st_size))
keep = files[0][0]
plan_kept[no] = keep
for f, _, _ in files[1:]:
plan_move.append((f, f"duplicate of invoice {no}"))
# Move hash-duplicates (exact bytes)
for h, group in files_by_hash.items():
if len(group) > 1:
# Keep one, move rest
group_sorted = sorted(group, key=lambda p: -p.stat().st_size)
for dup in group_sorted[1:]:
plan_move.append((dup, f"byte-identical duplicate of {group_sorted[0].name}"))
# Deduplicate the plan_move list (a file could be both a hash-dupe and an invoice-dupe)
seen = set()
final_move = []
for src, reason in plan_move:
if src not in seen and src not in plan_kept.values():
seen.add(src)
final_move.append((src, reason))
# Execute moves (to _non_jei)
print(f"\nMoving {len(final_move)} duplicate files to _non_jei/ ...")
for src, reason in final_move:
dest = NON_JEI / src.name
if dest.exists():
# Same name already in _non_jei — overwrite
dest.unlink()
shutil.move(str(src), str(dest))
print(f" MOVED: {src.name} ({reason})")
# Now rename kept files to invoice_no.pdf
print(f"\nRenaming {len(plan_kept)} files to invoice number...")
for no, src in plan_kept.items():
new_name = f"{no}.pdf"
# Skip if already correctly named
if src.name == new_name:
print(f" OK already: {src.name}")
continue
# Check for collision with another kept file
target = INVOICES / new_name
if target.exists() and target != src:
# collision — append a suffix
i = 2
while True:
alt = INVOICES / f"{no}_{i}.pdf"
if not alt.exists():
target = alt
new_name = alt.name
break
i += 1
src.rename(target)
print(f" RENAMED: {src.name} -> {new_name}")
# Final summary
print(f"\nFinal state:")
final_pdfs = sorted(INVOICES.glob("*.pdf"))
for p in final_pdfs:
print(f" {p.name}")
print(f"\n Total in invoices/: {len(final_pdfs)}")
if __name__ == "__main__":
main()