Files
mileage-reports/mileage_rate.py
John Lancaster dcd9bda8a9 updates v2
2025-11-17 13:20:04 -06:00

130 lines
4.0 KiB
Python

# /// script
# requires-python = ">=3.12"
# dependencies = ["typer", "rich", "xlwings"]
# ///
import json
import re
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import Annotated
import xlwings as xw
import typer
from rich.console import Console
console = Console()
def get_reports(base_dir: Path):
report_regex = re.compile(r'(?P<name>\w+)_2025_[\w ]+')
for report in base_dir.glob("*Mileage Report.xlsx"):
if not report.is_file() or report.stem.startswith('~$'):
continue
if m := report_regex.match(report.stem):
yield m.group("name"), report
def find_in_range(rng: xw.Range, query: str) -> xw.Range | None:
for cell in rng:
match cell.value:
case str(cell_value) if cell_value.lower().startswith(query.lower()):
return cell
return None
def find_total_row(table_start: xw.Range) -> int:
desc_col = find_in_range(table_start.expand('right'), "Desc")
col = desc_col.get_address(column_absolute=False)[0]
return find_in_range(table_start.sheet.range(f'{col}:{col}'), 'total').row
def get_total_cost(sheet: xw.Sheet) -> float:
table_start = find_in_range(sheet.range("A1:A10"), "Date")
cost_col = find_in_range(table_start.expand('right'), "Cost")
total_row = find_total_row(table_start)
cost_cell = sheet.range(f'{cost_col.get_address(column_absolute=False)[0]}{total_row}')
return cost_cell.value
def get_all_totals(book: xw.Book) -> Generator[str, float]:
for sheet in book.sheets:
if sheet.name.endswith('Rate'):
continue
total = float(get_total_cost(sheet))
console.print(f'Got ${total} for sheet {sheet.name}')
yield sheet.name, float(total)
def get_grand_total(book: xw.Book):
totals = dict(get_all_totals(book))
grand_total = sum(totals.values())
return grand_total
@contextmanager
def switch_values(book: xw.Book):
og_vals = book.sheets['GSA Rate'].range("B2").expand("down").value
try:
book.sheets['GSA Rate'].range("B2").expand("down").value = 0.70
yield
finally:
book.sheets['GSA Rate'].range("B2").expand("down").options(transpose=True).value = og_vals
def get_diff(book: xw.Book) -> dict[str, float]:
book.sheets['GSA Rate'].range("B2:B10").value = 0.67
console.print('Calculating initial total...')
initial_totals = dict(get_all_totals(book))
with switch_values(book):
console.print('Calculating new rate total...')
new_totals = dict(get_all_totals(book))
return {
'old_rate': initial_totals,
'new_rate': new_totals,
'difference': round(sum(new_totals.values()) - sum(initial_totals.values()), 2)
}
def get_all_diffs(base: Path):
reports = dict(get_reports(base))
console.print(f"Found {len(reports)} report(s).")
for name, report_path in reports.items():
with console.status(f'Processing [bold blue]{report_path.name}[/bold blue]'):
console.print('Opening workbook...')
book = xw.Book(report_path)
try:
res = get_diff(book)
console.print(f"[cyan]Report:[/] {report_path.name}")
console.print(f" Results: ${res}")
yield name, res
except Exception as e:
console.print(f"[red]Error processing {report_path.name}: {e}[/red]")
finally:
book.close()
def main(
directory: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
writable=True,
resolve_path=True
)
] = None) -> None:
console.print(f"Processing files in directory: {directory}")
results = dict(get_all_diffs(directory))
with Path('results.jsonl').open('w', encoding='utf-8') as f:
json.dump(results, f, indent=4)
if __name__ == "__main__":
typer.run(main)