diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/fxl b/fxl.py similarity index 100% rename from fxl rename to fxl.py diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 0000000..bfdcd0a --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,60 @@ +import streamlit as st +from trial_balance_parser import parse_trial_balance_to_excel + + +st.set_page_config( + page_title="MEDITECH Detail Trial Balance Parser", + page_icon="📄", + layout="wide", +) + + +st.title("MEDITECH Detail Trial Balance Parser") + +st.write( + "Upload a MEDITECH Detail Trial Balance PDF and export the parsed transactions " + "and account summary to Excel." +) + +uploaded_file = st.file_uploader( + "Upload Trial Balance PDF", + type=["pdf"], +) + +if uploaded_file is not None: + st.info(f"Uploaded file: {uploaded_file.name}") + + if st.button("Parse PDF"): + try: + with st.spinner("Parsing PDF..."): + excel_bytes, df_txn, df_acct, title = parse_trial_balance_to_excel(uploaded_file) + + st.success("Parsed successfully.") + + col1, col2 = st.columns(2) + + with col1: + st.metric("Transactions", len(df_txn)) + + with col2: + st.metric("Account Summary Rows", len(df_acct)) + + if title: + st.write(f"**Report Title:** {title}") + + st.subheader("Transactions Preview") + st.dataframe(df_txn, use_container_width=True) + + st.subheader("Account Summary Preview") + st.dataframe(df_acct, use_container_width=True) + + st.download_button( + label="Download Excel File", + data=excel_bytes, + file_name="meditech_trial_balance_parsed.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + except Exception as e: + st.error("Something went wrong while parsing the PDF.") + st.exception(e) \ No newline at end of file diff --git a/trial_balance_parser.py b/trial_balance_parser.py new file mode 100644 index 0000000..6f28029 --- /dev/null +++ b/trial_balance_parser.py @@ -0,0 +1,314 @@ +import re +import io +import pandas as pd +import pdfplumber +import openpyxl + +from openpyxl.styles import Font +from openpyxl.worksheet.table import Table, TableStyleInfo +from openpyxl.utils import get_column_letter + + +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" +) + + +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()) + + +def parse_trial_balance_pdf(pdf_file): + """ + Parses a MEDITECH detail trial balance PDF. + + Accepts either: + - a file path string + - a Streamlit uploaded file + - a file-like object + + Returns: + - df_txn + - df_acct + - title + """ + + transactions = [] + acct_summaries = [] + + report_title_parts = { + "fiscal": None, + "period": None, + "trial": None, + } + + with pdfplumber.open(pdf_file) as pdf: + for page in pdf.pages: + text = page.extract_text(layout=True) or "" + lines = text.splitlines() + + 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" + + txn_header = None + 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 + + 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 + + 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 + + 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 = None + 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 + + if last_txn: + add = clean_description(s) + + if add: + last_txn["DESCRIPTION"] += " " + add + + i += 1 + continue + + i += 1 + + title = ", ".join(v for v in report_title_parts.values() if v) + + df_txn = pd.DataFrame(transactions) + df_acct = pd.DataFrame(acct_summaries) + + return df_txn, df_acct, title + + +def build_excel_file(df_txn, df_acct, title): + """ + Builds a formatted Excel workbook in memory. + + Returns: + - bytes suitable for Streamlit download_button + """ + + initial_buffer = io.BytesIO() + + with pd.ExcelWriter(initial_buffer, 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) + + initial_buffer.seek(0) + + wb = openpyxl.load_workbook(initial_buffer) + + for sheet_name in ["Transactions", "Account Summary"]: + ws = wb[sheet_name] + + 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" + + header_row = 3 + + if ws.max_row >= header_row and ws.max_column >= 1: + end_col = get_column_letter(ws.max_column) + table_ref = f"A{header_row}:{end_col}{ws.max_row}" + + table_name = sheet_name.replace(" ", "") + "Table" + + tab = Table(displayName=table_name, ref=table_ref) + + style = TableStyleInfo( + name="TableStyleMedium9", + showFirstColumn=False, + showLastColumn=False, + showRowStripes=True, + showColumnStripes=False, + ) + + tab.tableStyleInfo = style + ws.add_table(tab) + + for col in ws.columns: + max_length = 0 + col_letter = get_column_letter(col[0].column) + + for cell in col: + try: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + except Exception: + pass + + ws.column_dimensions[col_letter].width = min(max_length + 2, 60) + + final_buffer = io.BytesIO() + wb.save(final_buffer) + final_buffer.seek(0) + + return final_buffer.getvalue() + + +def parse_trial_balance_to_excel(pdf_file): + """ + Main function for Streamlit or command-line use. + + Returns: + - excel_bytes + - df_txn + - df_acct + - title + """ + + df_txn, df_acct, title = parse_trial_balance_pdf(pdf_file) + excel_bytes = build_excel_file(df_txn, df_acct, title) + + return excel_bytes, df_txn, df_acct, title \ No newline at end of file