From 231f5ed4ce79a257e6e8bae9a15512f2c8c3d0c9 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 15 May 2023 17:57:27 -0400 Subject: [PATCH] - Integrated new ReportConfig into program - Added full test to check everything works as expected after small changes - A bit of project restructuring, with switch to absolute imports --- .gitignore | 2 +- src/config.py | 12 +++ src/configs/config_logger.toml | 2 +- src/configs/reports_config.toml | 8 +- src/helpers.py | 31 +----- src/hold_reconciler.py | 92 +++++++----------- src/memory.py | 32 ++++-- src/reports.py | 48 ++++----- tests/__init__.py | 0 tests/context.py | 5 - tests/test_config.py | 7 +- tests/test_inputs/April Reconciled Holds.xlsx | Bin 25235 -> 0 bytes tests/test_inputs/TEST_reports_config.toml | 6 +- .../{ => TestSearch}/April 2023 OB.xlsx | Bin .../{ => TestSearch}/April GP.xlsx | Bin tests/test_report.py | 78 +++++++++++++++ 16 files changed, 185 insertions(+), 138 deletions(-) create mode 100644 tests/__init__.py delete mode 100644 tests/context.py delete mode 100644 tests/test_inputs/April Reconciled Holds.xlsx rename tests/test_inputs/{ => TestSearch}/April 2023 OB.xlsx (100%) rename tests/test_inputs/{ => TestSearch}/April GP.xlsx (100%) create mode 100644 tests/test_report.py diff --git a/.gitignore b/.gitignore index 974a291..41cb7c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ ghlib/ *.txt !version.txt -!tests/test_inputs/* \ No newline at end of file +!tests/test_inputs/TestSearch/* \ No newline at end of file diff --git a/src/config.py b/src/config.py index a05346d..e40bd8a 100644 --- a/src/config.py +++ b/src/config.py @@ -56,6 +56,12 @@ class PathsConfig: pass # will remain as *.xlsx def get_most_recent(self, report_type: ReportSource = None) -> Path|None| tuple[Path|None, Path|None]: + """ + Gets the most recent hold reports for OnBase and Great Plains. + If no report type is specified both OnBase & GreatPlains are returned. + + If no matching reports are found, None will be returned + """ report_files = [] report_types = [ReportSource.OB, ReportSource.GP] if report_type is None else [report_type] @@ -102,6 +108,12 @@ class PathsConfig: @dataclass class ReportConfig: + """ + Allows easy interaction with program configuration. + - Paths to files, db + - Report/Excel column naming + - Regexes + """ # Paths to work with # - input/output diff --git a/src/configs/config_logger.toml b/src/configs/config_logger.toml index c29dad5..9b8572e 100644 --- a/src/configs/config_logger.toml +++ b/src/configs/config_logger.toml @@ -18,5 +18,5 @@ formatter = "custom" filename = "on_hold.log" [root] -level = "DEBUG" +level = "ERROR" handlers = ["console", "file"] \ No newline at end of file diff --git a/src/configs/reports_config.toml b/src/configs/reports_config.toml index 4a85a94..b98275d 100644 --- a/src/configs/reports_config.toml +++ b/src/configs/reports_config.toml @@ -1,11 +1,11 @@ #### Paths: using '' makes the string 'raw' to avoid escape characters # Path to the directory to search for input report files -input_directory = '../Reports' +input_directory = 'Work/Reports' # Regex used to discover newest files input_glob_pattern = { GP = "*GP*.xlsx", OB = '*OB*.xlsx'} # Path to the directory to save the reconcilation work report -output_directory = '../Output' +output_directory = 'Work/Output' # Fallback to interactive? interactive_inputs = false # NOT YET IMPLEMENTED @@ -16,7 +16,7 @@ interactive_inputs = false # NOT YET IMPLEMENTED # NOT YET IMPLEMENTED! use_mssql = false # Path to the SQLite database used to view/save reconcilations -database_path = './onhold_reconciliation.db' +database_path = 'src/onhold_reconciliation.db' ### Finished rec details @@ -53,7 +53,7 @@ doc_num_filters = [ "rent", "cma" ] -po_filter = ["^(?!.*cma(\\s|\\d)).*$"] +po_filter = ['(?i)^(?!.*cma(\s|\d)).*$'] # Columns that are featured & expected on both OB & GP [[shared_columns]] diff --git a/src/helpers.py b/src/helpers.py index 5e4261d..c9005f7 100644 --- a/src/helpers.py +++ b/src/helpers.py @@ -38,7 +38,7 @@ def setup_logging(): Returns: logging.Logger: The logger instance. """ - with open("config_logger.toml", "rb") as f: + with open("src/configs/config_logger.toml", "rb") as f: config_dict: dict = load(f) try: # Try to load logging configuration from the TOML file @@ -60,31 +60,4 @@ def drop_unnamed(df: DataFrame, inplace: bool = True) -> DataFrame|None: (on the orignal dataframe, not a copy!) """ cols = [c for c in df.columns if "Unnamed" in c] - return df.drop(cols, axis=1, inplace=inplace) - - -def find_most_recent_file(folder_path: Path, file_pattern: Pattern) -> str: - """ - Given a folder path and a regular expression pattern, this function returns the path of the most recently modified - file in the folder that matches the pattern. - - Args: - folder_path (Path): A pathlib.Path object representing the folder to search. - file_pattern (Pattern): A regular expression pattern used to filter the files in the folder. - - Returns: - str: The path of the most recently modified file in the folder that matches the pattern. - """ - # Find all files in the folder that match the pattern - files = glob.glob(f"{folder_path}/*") - logger.debug(f"files: {files}") - - # Get the modification time of each file and filter to only those that match the pattern - file_times = [(os.path.getmtime(path), path) for path in files if re.match(file_pattern, basename(path))] - - # Sort the files by modification time (most recent first) - file_times.sort(reverse=True) - logger.debug(f"file times: {file_times}") - - # Return the path of the most recent file - return file_times[0][1] + return df.drop(cols, axis=1, inplace=inplace) \ No newline at end of file diff --git a/src/hold_reconciler.py b/src/hold_reconciler.py index e8747d0..fd4bf5a 100644 --- a/src/hold_reconciler.py +++ b/src/hold_reconciler.py @@ -4,11 +4,13 @@ then utilizes the reconcile module to find the differences between them. The out saved as an excel file with todays date. """ # Custom module for reconciliation -from helpers import setup_logging, find_most_recent_file -from reports import OnBaseReport, GreatPlainsReport, ReconciledReports +from src.helpers import setup_logging +from src.reports import OnBaseReport, GreatPlainsReport, ReconciledReports +from src.config import ReportConfig +from src import ReportSource import pandas as pd -from pandas import DataFrame +from pandas import DataFrame, read_excel, ExcelFile import re from re import Pattern import logging @@ -22,54 +24,26 @@ logger = logging.getLogger(__name__) logger.info(f"Logger started with level: {logger.level}") -def get_reports(work_dir: str, report_config: dict) -> tuple[pd.DataFrame|None, pd.DataFrame|None]: - """ - Given a dictionary of Excel configuration options, this function searches for the most recently modified GP and OB - Excel files in a "Work" folder and returns their corresponding dataframes. - - Args: - excelConfig (dict): A dictionary containing configuration options for the GP and OB Excel files. - - Returns: - tuple[pd.DataFrame|None, pd.DataFrame|None]: A tuple containing the OB and GP dataframes, respectively. - """ - - # Define regular expression patterns to match the GP and OB Excel files - gp_regex: Pattern = re.compile(".*gp.*\.xlsx$", re.IGNORECASE) - ob_regex: Pattern = re.compile(".*ob.*\.xlsx$", re.IGNORECASE) - - # Find the paths of the most recently modified GP and OB Excel files - gp_file_path = find_most_recent_file(work_dir, gp_regex) - logger.debug(f"gp_file_path: {gp_file_path}") - ob_file_path = find_most_recent_file(work_dir, ob_regex) - logger.debug(f"gp_file_path: {ob_file_path}") +def pull_report_sheet(report_path: Path, report_source: ReportSource, report_config: ReportConfig) -> DataFrame|None: - # Read the GP and OB Excel files into dataframes and check that each dataframe has the required columns - gp_xl = pd.ExcelFile(gp_file_path) - gp_req_cols = [col["GP"] for _, col in report_config["shared_columns"].items()] - logger.debug(f"GP_Req_cols: {gp_req_cols}") - gp_sheets = gp_xl.sheet_names - gp_dfs = pd.read_excel(gp_xl, sheet_name=gp_sheets) - for sheet in gp_dfs: - sheet_columns: list[str] = list(gp_dfs[sheet].columns) - logger.debug(f"gp ({sheet}) : {sheet_columns}") - logger.debug(f"Matches {[r in sheet_columns for r in gp_req_cols]}") - if all([r in sheet_columns for r in gp_req_cols]): - logger.debug("FOUND") - gp_df = gp_dfs[sheet] - break - - ob_xl = pd.ExcelFile(ob_file_path) - ob_req_cols = [col["OB"] for _, col in report_config["shared_columns"].items()] - ob_sheets = ob_xl.sheet_names - ob_dfs = pd.read_excel(ob_xl, sheet_name=ob_sheets) - for sheet in ob_dfs: - sheet_columns: list[str] = list(ob_dfs[sheet].columns) - if all([r in sheet_columns for r in ob_req_cols]): - ob_df = ob_dfs[sheet] - break + xl_file = ExcelFile(report_path) + # Get the columns required to be a valid report for the given report type + req_cols = [col[report_source.value] for col in report_config.shared_columns] - return ob_df, gp_df + logger.debug(f"GP_Req_cols: {req_cols}") + # Sheets avaialble in the excel file + sheets = xl_file.sheet_names + # Dictionary of dataframes keyed by their sheet name + sheet_dataframes: dict[str:DataFrame] = read_excel(xl_file, sheet_name=sheets) + # Check each dataframe for the required column + for sheet in sheet_dataframes: + sheet_columns: list[str] = list(sheet_dataframes[sheet].columns) + logger.debug(f"{report_source.value} ({sheet}) : {sheet_columns}") + logger.debug(f"Matches {[r in sheet_columns for r in req_cols]}") + if all([r in sheet_columns for r in req_cols]): + logger.debug(f"FOUND: {sheet}") + return sheet_dataframes[sheet] + return None def main() -> int: @@ -80,23 +54,25 @@ def main() -> int: Returns: int: 0 if the script executes successfully. """ - # Read the configuration options from a TOML file - with open("config_reports.toml", "rb") as f: - reports_config: dict = load(f) - logger.debug(f"Reports Config: {reports_config}") - + # Read the configuration options + report_config: ReportConfig = ReportConfig.from_file(Path("src/configs/reports_config.toml")) + # Get the GP and OB dataframes from the Excel files - ob_df, gp_df = get_reports("Work", reports_config) + ob_report, gp_report = report_config.paths.get_most_recent() + print(ob_report) + print(gp_report) + ob_df: DataFrame = pull_report_sheet(ob_report, ReportSource.OB, report_config) + gp_df: DataFrame = pull_report_sheet(gp_report, ReportSource.GP, report_config) assert not ob_df.empty, "OB Data empty!" assert not gp_df.empty, "GP Data empty!" - obr: OnBaseReport = OnBaseReport(ob_df, reports_config) - gpr: GreatPlainsReport = GreatPlainsReport(gp_df, reports_config) + obr: OnBaseReport = OnBaseReport(ob_df, report_config) + gpr: GreatPlainsReport = GreatPlainsReport(gp_df, report_config) rec_output: ReconciledReports = obr.reconcile(gpr) output_name: Path = Path(f"Reconciled Holds [{dt.now().strftime('%m-%d-%Y')}].xlsx") - output_base: Path = Path(reports_config["output_path"]) + output_base: Path = report_config.paths.output_directory output_path: Path = Path(output_base, output_name) rec_output.save_reports(output_path) diff --git a/src/memory.py b/src/memory.py index d84a5dc..2760c5a 100644 --- a/src/memory.py +++ b/src/memory.py @@ -7,9 +7,11 @@ resolved holds. *Last Updated: version 1.3 """ -from helpers import drop_unnamed, setup_logging -from ghlib.database.database_manager import SQLiteManager, select_fields_statement +from src.helpers import drop_unnamed, setup_logging +from src.config import ReportConfig, ReportSource +from src.ghlib.database.database_manager import SQLiteManager, select_fields_statement +from pathlib import Path from pandas import DataFrame, Series, read_sql_query, read_excel, concat from numpy import NaN from logging import getLogger @@ -28,6 +30,15 @@ def hash_cols(row: Series, cols_to_hash: list[str]) -> col_hash: return md5_hash.hexdigest() def create_identifier(df: DataFrame) -> DataFrame: + """ + We want to create a unqiue and replicable ID to identify each payment pair. + Some transactions may have 1 blank ID which can cause an undeterimable hash. + For this reason we must replace empty IDs with x so that it will have a replicable + value. + + Then the two ideas are hashed together using md5. Resulting in a unique 32 character + identifier that can be reproduced. + """ for id in ["ID_OB","ID_GP"]: df[id].fillna("x", inplace=True) df["Indentifier"] = df.apply(lambda row: @@ -37,10 +48,10 @@ def create_identifier(df: DataFrame) -> DataFrame: df[id].replace('x',NaN, inplace=True) return df -def save_rec(resolved_dataframes: list[DataFrame]): +def save_rec(resolved_dataframes: list[DataFrame], report_config: ReportConfig): """ """ - sqlManager: SQLiteManager = SQLiteManager("OnHold.db") + sqlManager: SQLiteManager = SQLiteManager(report_config.paths.db_path) with sqlManager.get_session() as session: rdf: DataFrame @@ -66,14 +77,13 @@ def save_rec(resolved_dataframes: list[DataFrame]): "Indentifier", "ID_GP", "ID_OB", - "HideNextMonth", - "Resolution" ] + rec_cols.extend(report_config.work_columns) rdf = rdf[rec_cols] rdf.set_index("Indentifier", inplace=True, drop=True) rdf.drop_duplicates(inplace=True) - rdf = rdf.dropna(axis=0, how="all", subset=["HideNextMonth", "Resolution"]) + rdf = rdf.dropna(axis=0, how="all", subset=report_config.work_columns) logger.debug(f"Saving resolutions to db:\n{rdf}") rdf.to_sql('Resolutions', @@ -83,7 +93,7 @@ def save_rec(resolved_dataframes: list[DataFrame]): -def get_prev_reconciled(identfiers: list[col_hash]) -> DataFrame|None: +def get_prev_reconciled(identfiers: list[col_hash], db_location: Path) -> DataFrame|None: """ Get a DataFrame of previously reconciled contracts from an SQLite database. @@ -94,7 +104,7 @@ def get_prev_reconciled(identfiers: list[col_hash]) -> DataFrame|None: DataFrame: A DataFrame of previously reconciled contracts, or an empty DataFrame if none are found. """ # Create a DB manager - sqlManager: SQLiteManager = SQLiteManager("OnHold.db") + sqlManager: SQLiteManager = SQLiteManager(db_location) # Create a temp table to hold this batches contract numbers # this table will be cleared when sqlManager goes out of scope @@ -139,5 +149,7 @@ if __name__ == "__main__": no_match: DataFrame = read_excel(args.input, sheet_name="No Match") # Amount Mismatch amt_mm: DataFrame = read_excel(args.input, sheet_name="Amount Mismatch") + + report_config = ReportConfig(Path(r"configs\reports_config.toml")) - save_rec(resolved_dataframes=[no_match, amt_mm]) \ No newline at end of file + save_rec(report_config, resolved_dataframes=[no_match, amt_mm]) \ No newline at end of file diff --git a/src/reports.py b/src/reports.py index fd1fa17..9a45d1d 100644 --- a/src/reports.py +++ b/src/reports.py @@ -3,13 +3,16 @@ from openpyxl import Workbook, load_workbook from abc import ABC from logging import getLogger import re +from re import Pattern import datetime from copy import deepcopy from dataclasses import dataclass -from helpers import CN_REGEX, drop_unnamed -from memory import get_prev_reconciled, hash_cols, col_hash, create_identifier from pathlib import Path +from src.helpers import CN_REGEX, drop_unnamed +from src.memory import get_prev_reconciled, hash_cols, col_hash, create_identifier +from src.config import ReportConfig, ReportSource + logger = getLogger(__name__) @dataclass @@ -54,19 +57,19 @@ class HoldReport(ABC): source = "" - def __init__(self, dataframe: DataFrame, reports_config: dict) -> None: + def __init__(self, dataframe: DataFrame, reports_config: ReportConfig) -> None: self.config = reports_config drop_unnamed(dataframe) self.df = dataframe - self.df = self._add_work_columns(self.df) + self.df = self._add_work_columns(self.df, reports_config.work_columns) self._normalize() def _normalize(self): # Rename the columns to standardize the column names - self.df.rename( columns= { unique_cols[self.source] : common_col - for common_col, unique_cols in self.config["shared_columns"].items() + self.df.rename( columns= { sc_dict[self.source] : sc_dict["standardized_name"] + for sc_dict in self.config.shared_columns }, inplace=True) # Convert the on-hold amount column to float format and round to two decimal places @@ -87,7 +90,7 @@ class HoldReport(ABC): @staticmethod - def _remove_prev_recs(contract_match, no_match) -> \ + def _remove_prev_recs(contract_match, no_match, db_location: Path) -> \ tuple[DataFrame, DataFrame, DataFrame]: """ """ @@ -96,7 +99,7 @@ class HoldReport(ABC): idents.extend(create_identifier(no_match)["Indentifier"].to_list()) logger.debug(f"{idents=}") # Get previsouly reced - prev_recs: DataFrame|None = get_prev_reconciled(idents) + prev_recs: DataFrame|None = get_prev_reconciled(idents, db_location) if prev_recs is None: logger.info("No previously reconciled!") @@ -205,19 +208,20 @@ class HoldReport(ABC): no_match = create_identifier(no_match) logger.debug(f"_requires_rec | no_match:\n{no_match.columns} ({no_match.shape})") - self.prev_recs, contract_match, no_match = self._remove_prev_recs(contract_match, no_match) + self.prev_recs, contract_match, no_match = self._remove_prev_recs(contract_match, + no_match, self.config.paths.db_path + ) return contract_match, no_match @staticmethod - def _add_work_columns(df: DataFrame) -> DataFrame: + def _add_work_columns(df: DataFrame, work_cols: list) -> DataFrame: """ Add empty columns to the dataframe to faciliate working through the report. """ logger.debug("Adding work columns!") df_cols: list[str] = df.columns.to_list() - WORK_COLS = ["HideNextMonth","Resolution"] - for col in WORK_COLS: + for col in work_cols: if col not in df_cols: df[col] = '' return df @@ -245,7 +249,7 @@ class HoldReport(ABC): # Formatting columns: list[str] = ["ID_GP", "ID_OB"] - columns.extend(self.config["output_columns"]) + columns.extend(self.config.finished_columns) nm_cols:list[str] = deepcopy(columns) nm_cols.insert(3,"onhold_amount") @@ -265,8 +269,6 @@ class HoldReport(ABC): logger.info(f"no_match: {no_match.shape[0]}") logger.info(f"am_mm: {amount_mismatch.shape[0]}") - - reconciled: ReconciledReports = ReconciledReports( no_match=no_match, amt_mismatch=amount_mismatch, @@ -281,7 +283,7 @@ class OnBaseReport(HoldReport): source = "OB" - def __init__(self, dataframe: DataFrame, reports_config: dict) -> None: + def __init__(self, dataframe: DataFrame, reports_config: ReportConfig) -> None: self.overdue = self._get_overdue(dataframe) super().__init__(dataframe, reports_config) @@ -300,24 +302,24 @@ class GreatPlainsReport(HoldReport): source = "GP" - def __init__(self, dataframe: DataFrame, report_config: dict) -> None: + def __init__(self, dataframe: DataFrame, report_config: ReportConfig) -> None: self.filtered: DataFrame = self._filter( gp_report_df= dataframe, - doc_num_filters= report_config["gp_filters"]["doc_num_filters"], - good_po_num_regex= report_config["gp_filters"]["po_filter"] + doc_num_filters= report_config.filters["doc_num_filters"], + good_po_num_regex= report_config.filters["po_filter"][0] ) super().__init__(dataframe, report_config) @staticmethod def _filter(gp_report_df: DataFrame, - doc_num_filters: list[str], good_po_num_regex: str + doc_num_filters: list[Pattern], good_po_num_regex: Pattern ) -> DataFrame: - GOOD_PO_NUM = re.compile(good_po_num_regex, re.IGNORECASE) + GOOD_PO_NUM = good_po_num_regex - bad_doc_num = '' - rx : str + bad_doc_num = '(?i)' + rx : Pattern for rx in doc_num_filters: bad_doc_num += f"({rx})|" bad_doc_num = re.compile(bad_doc_num[:-1], re.IGNORECASE) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/context.py b/tests/context.py deleted file mode 100644 index 1bea8fb..0000000 --- a/tests/context.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -import src \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 495c171..b238cd1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,6 @@ import unittest from pathlib import Path from re import Pattern, compile -from .context import src from src import config from src import ReportSource @@ -15,12 +14,12 @@ class TestReportConfig(unittest.TestCase): report_config = config.ReportConfig.from_file(config_file) # Assert the values of the attributes in the created instance - self.assertEqual(report_config.paths.input_directory, Path(r"tests\test_inputs")) + self.assertEqual(report_config.paths.input_directory, Path(r"tests\test_inputs\TestSearch")) self.assertEqual(report_config.paths.gp_glob, r'*GP*.xlsx') self.assertEqual(report_config.paths.ob_glob, r"*OB*.xlsx") self.assertEqual(report_config.paths.output_directory, Path(r"tests\test_outputs")) self.assertEqual(report_config.use_mssql, False) - self.assertEqual(report_config.paths.db_path, Path("./onhold_reconciliation.db")) + self.assertEqual(report_config.paths.db_path, Path(r"tests\test_inputs\Static\test_static_OnHold.db")) self.assertEqual(report_config.work_columns, ["HideNextMonth", "Resolution"]) self.assertEqual(report_config.finished_columns, [ "contract_number", @@ -42,7 +41,7 @@ class TestReportConfig(unittest.TestCase): compile(r"rent",), compile(r"cma",), ]) - self.assertEqual(report_config.filters["po_filter"], [compile(r"^(?!.*cma(\s|\d)).*$")]) + self.assertEqual(report_config.filters["po_filter"], [compile(r"(?i)^(?!.*cma(\s|\d)).*$")]) self.assertEqual(report_config.shared_columns[0]["standardized_name"], "contract_number") self.assertEqual(report_config.shared_columns[0]["GP"], "Transaction Description") self.assertEqual(report_config.shared_columns[0]["OB"], "Contract") diff --git a/tests/test_inputs/April Reconciled Holds.xlsx b/tests/test_inputs/April Reconciled Holds.xlsx deleted file mode 100644 index 2fb1e9aa404885c24d68545af420a0cf4bb14cdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25235 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 DGkfY0 diff --git a/tests/test_inputs/TEST_reports_config.toml b/tests/test_inputs/TEST_reports_config.toml index 80a2754..c83aa3f 100644 --- a/tests/test_inputs/TEST_reports_config.toml +++ b/tests/test_inputs/TEST_reports_config.toml @@ -1,7 +1,7 @@ #### Paths: using '' makes the string 'raw' to avoid escape characters # Path to the directory to search for input report files -input_directory = 'tests\test_inputs' +input_directory = 'tests\test_inputs\TestSearch' # Regex used to discover newest files input_glob_pattern = { GP = "*GP*.xlsx", OB = '*OB*.xlsx'} # Path to the directory to save the reconcilation work report @@ -16,7 +16,7 @@ interactive_inputs = false # NOT YET IMPLEMENTED # NOT YET IMPLEMENTED! use_mssql = false # Path to the SQLite database used to view/save reconcilations -database_path = './onhold_reconciliation.db' +database_path = 'tests\test_inputs\Static\test_static_OnHold.db' ### Finished rec details @@ -53,7 +53,7 @@ doc_num_filters = [ "rent", "cma" ] -po_filter = ['^(?!.*cma(\s|\d)).*$'] +po_filter = ['(?i)^(?!.*cma(\s|\d)).*$'] # Columns that are featured & expected on both OB & GP [[shared_columns]] diff --git a/tests/test_inputs/April 2023 OB.xlsx b/tests/test_inputs/TestSearch/April 2023 OB.xlsx similarity index 100% rename from tests/test_inputs/April 2023 OB.xlsx rename to tests/test_inputs/TestSearch/April 2023 OB.xlsx diff --git a/tests/test_inputs/April GP.xlsx b/tests/test_inputs/TestSearch/April GP.xlsx similarity index 100% rename from tests/test_inputs/April GP.xlsx rename to tests/test_inputs/TestSearch/April GP.xlsx diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..0831237 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,78 @@ +from pandas import DataFrame, merge, to_datetime, NaT, concat, read_excel +from pathlib import Path +from re import Pattern +import pytest as pt + +from src.config import ReportConfig, ReportSource +from src.reports import GreatPlainsReport, OnBaseReport, ReconciledReports +from src.hold_reconciler import pull_report_sheet + +class TestReport: + + @pt.fixture(autouse=True) + def setup(self): + self.report_config = ReportConfig.from_file( + Path(r"./tests/test_inputs/TEST_reports_config.toml") + ) + + + def test_full(self): + """ + Full process test. + + This tests inputs will need to be adjust anytime a change is made to the + input/output report layouts, filtering, trimming, normalization. + + Basically, this is just to make sure everything still works after making + TINY changes, that are not meant to effect the structure/logic of the program + """ + + ob_df = pull_report_sheet( + Path(r"./tests/test_inputs\Static\April 2023 OB.xlsx"), + ReportSource.OB, + self.report_config + ) + gp_df = pull_report_sheet( + Path(r"./tests/test_inputs\Static\April GP.xlsx"), + ReportSource.GP, + self.report_config + ) + + assert not ob_df.empty, "OB Data empty!" + assert not gp_df.empty, "GP Data empty!" + + obr: OnBaseReport = OnBaseReport(ob_df, self.report_config) + gpr: GreatPlainsReport = GreatPlainsReport(gp_df, self.report_config) + + rec_output: ReconciledReports = obr.reconcile(gpr) + + output_path: Path = Path( + self.report_config.paths.output_directory, + "TEST_REPORT.xlsx" + ) + rec_output.save_reports(output_path) + + SHEET_NAMES = [ + "No Match", + "Amount Mismatch", + "Overdue", + "Previously Reconciled", + "Filtered from GP", + ] + + CONTROL: dict[str:DataFrame] = read_excel( + Path(r"./tests/test_inputs/Static/Reconciled Holds [TEST_FIN].xlsx"), + sheet_name=SHEET_NAMES + ) + + new: dict[str:DataFrame] = read_excel( + output_path, + sheet_name=SHEET_NAMES + ) + + for sheet in SHEET_NAMES: + print(sheet) + print(new[sheet]) + print("Control: ") + print(CONTROL[sheet]) + assert new[sheet].equals(CONTROL[sheet])