From ba29105bd200fb0488b8fd024ec2132fa03872fa Mon Sep 17 00:00:00 2001 From: jmulhall00 Date: Tue, 30 Jun 2026 14:50:08 +0000 Subject: [PATCH] Add fxl --- fxl | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 fxl diff --git a/fxl b/fxl new file mode 100644 index 0000000..311c206 --- /dev/null +++ b/fxl @@ -0,0 +1,222 @@ +# --- install (if needed) --- +# !pip install pdfplumber pandas openpyxl + +import re +import os +import pandas as pd +import pdfplumber +import openpyxl +from openpyxl.styles import Font, Alignment +from openpyxl.worksheet.table import Table, TableStyleInfo + +# ----------------------------- +# Paths +# ----------------------------- +pdf_path = r"C:\Users\jmulhall\Downloads\DTB_janfeb2026.pdf" +out_xlsx = "meditech_trial_balance_parsed_0630.xlsx" + +# ----------------------------- +# Regex +# ----------------------------- +money_re = re.compile(r"-?\d{1,3}(?:,\d{3})*(?:\.\d{2})") +acct_re = re.compile(r"^(?P\d{2}\.\d{5}\.\d{5})\s+-\s+(?P\d{2})\s+(?P.+)$") +journal_re = re.compile( + r"^(?P[A-Z][A-Z0-9/]*)\s+" + r"(?P\d{2}/\d{2}/\d{2})(?P\d{1,2})?\s+" + r"(?P\d+(?:-\d+)?)\b" +) + +# ----------------------------- +# Helpers +# ----------------------------- +def parse_money(s): + if not s: + return None + m = money_re.search(s) + return float(m.group(0).replace(",", "")) if m else None + +def is_page_header_noise(s): + s = " ".join(s.split()).upper() + return ( + ("FISCAL CALENDAR" in s or "FIRST CALENDAR" in s) + and ("ROUND MONEY" in s or "TRIAL" in s) + ) + +def clean_description(desc): + if not desc or is_page_header_noise(desc): + return "" + patterns = [ + r"FISCAL\s+CALENDAR\s+[A-Z]{3,6}\s*,?\s*[A-Z]{3}\s+20\d{2}", + r"FIRST\s+CALENDAR\s+[A-Z]{3,6}\s*,?\s*[A-Z]{3}\s+20\d{2}", + r"ROUND\s+MONEY\s*:\s*\d+\.\d+", + r"\bTRIAL\b", + ] + for p in patterns: + desc = re.sub(p, "", desc, flags=re.IGNORECASE) + return " ".join(desc.split()) + +# ----------------------------- +# Parse PDF +# ----------------------------- +transactions = [] +acct_summaries = [] +report_title_parts = {"fiscal": None, "period": None, "trial": None} + +with pdfplumber.open(pdf_path) as pdf: + for page in pdf.pages: + text = page.extract_text(layout=True) or "" + lines = text.splitlines() + + # Capture report title once + for ln in lines: + s = ln.strip() + if report_title_parts["fiscal"] is None and "Fiscal Calendar" in s: + report_title_parts["fiscal"] = " ".join(s.split()) + if report_title_parts["period"] is None and re.search(r"\b[A-Z]{3}\s+20\d{2}\b", s): + report_title_parts["period"] = " ".join(s.split()) + if report_title_parts["trial"] is None and s.upper() == "TRIAL": + report_title_parts["trial"] = "TRIAL" + + # Locate headers + txn_header = acct_header = None + for i, ln in enumerate(lines): + if "JOURNAL" in ln and "DESCRIPTION" in ln and "DEBITS" in ln: + txn_header = i + if "ACCOUNT" in ln and "OPEN" in ln and "CLOSE" in ln: + acct_header = i + + deb_pos = cred_pos = desc_pos = None + open_pos = adeb_pos = acredit_pos = change_pos = close_pos = None + + if txn_header is not None: + h = lines[txn_header] + deb_pos = h.find("DEBITS") + cred_pos = h.find("CREDITS", deb_pos) + desc_pos = h.find("DESCRIPTION", cred_pos) + + if acct_header is not None: + h = lines[acct_header] + open_pos = h.find("OPEN") + adeb_pos = h.find("DEBITS", open_pos) + acredit_pos = h.find("CREDITS", adeb_pos) + change_pos = h.find("CHANGE", acredit_pos) + close_pos = h.find("CLOSE", change_pos) + + current_account = None + current_account_name = None + pending_totals = False + last_txn = None + i = 0 + + while i < len(lines): + ln = lines[i] + s = ln.strip() + + if not s or is_page_header_noise(s): + i += 1 + continue + + # ACCOUNT header + m = acct_re.match(s) + if m: + current_account = m.group("account") + current_account_name = m.group("name").strip() + pending_totals = True + last_txn = None + i += 1 + continue + + # ACCOUNT totals + if pending_totals and money_re.search(s): + nums = [float(x.replace(",", "")) for x in money_re.findall(s)] + nums += [None] * 5 + acct_summaries.append({ + "ACCOUNT_NAME": current_account_name, + "ACCOUNT": current_account, + "OPEN": nums[0], + "TOTAL_DEBITS": nums[1], + "TOTAL_CREDITS": nums[2], + "NET_CHANGE": nums[3], + "CLOSE": nums[4], + }) + pending_totals = False + i += 1 + continue + + # TRANSACTION + m = journal_re.match(s) + if m and current_account: + journal = m.group("journal") + date = m.group("date") + bch = int(m.group("bch")) if m.group("bch") else None + entry = m.group("entry") + + debit = credit = None + desc = "" + + if deb_pos is not None and cred_pos is not None: + debit = parse_money(ln[deb_pos:cred_pos]) + credit = parse_money(ln[cred_pos:desc_pos]) + desc = ln[desc_pos:].strip() + else: + after = s[m.end():].strip() + nums = money_re.findall(after) + if nums: + debit = float(nums[0].replace(",", "")) + desc = money_re.sub("", after).strip() + + row = { + "ACCOUNT_NAME": current_account_name, + "ACCOUNT": current_account, + "JOURNAL": journal, + "DATE": date, + "BCH": bch, + "ENTRY": entry, + "DEBITS": debit, + "CREDITS": credit, + "DESCRIPTION": clean_description(desc), + } + transactions.append(row) + last_txn = row + i += 1 + continue + + # Continuation DESCRIPTION + if last_txn: + add = clean_description(s) + if add: + last_txn["DESCRIPTION"] += " " + add + i += 1 + continue + + i += 1 + +# ----------------------------- +# Finalize +# ----------------------------- +title = ", ".join(v for v in report_title_parts.values() if v) + +df_txn = pd.DataFrame(transactions) +df_acct = pd.DataFrame(acct_summaries) + +# ----------------------------- +# Export XLSX +# ----------------------------- +with pd.ExcelWriter(out_xlsx, engine="openpyxl") as writer: + df_txn.to_excel(writer, "Transactions", index=False, startrow=2) + df_acct.to_excel(writer, "Account Summary", index=False, startrow=2) + +wb = openpyxl.load_workbook(out_xlsx) +for sheet in ["Transactions", "Account Summary"]: + ws = wb[sheet] + ws["A1"] = title + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=ws.max_column) + ws["A1"].font = Font(bold=True) + ws.freeze_panes = "A4" + +wb.save(out_xlsx) + +print("✔ Parsed successfully") +print("Transactions:", df_txn.shape) +print("Account Summary:", df_acct.shape) +print("Output:", out_xlsx) \ No newline at end of file