mt940-py 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mt940_py-0.1.5/PKG-INFO +7 -0
- mt940_py-0.1.5/README.md +25 -0
- mt940_py-0.1.5/pyproject.toml +30 -0
- mt940_py-0.1.5/setup.cfg +4 -0
- mt940_py-0.1.5/src/mt940_py/converter.py +162 -0
- mt940_py-0.1.5/src/mt940_py/exporter.py +77 -0
- mt940_py-0.1.5/src/mt940_py/gui.py +115 -0
- mt940_py-0.1.5/src/mt940_py/main.py +75 -0
- mt940_py-0.1.5/src/mt940_py/validator.py +84 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/PKG-INFO +7 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/SOURCES.txt +15 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/dependency_links.txt +1 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/entry_points.txt +2 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/requires.txt +1 -0
- mt940_py-0.1.5/src/mt940_py.egg-info/top_level.txt +1 -0
- mt940_py-0.1.5/tests/test_converter.py +35 -0
- mt940_py-0.1.5/tests/test_validator.py +47 -0
mt940_py-0.1.5/PKG-INFO
ADDED
mt940_py-0.1.5/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# mt940-py
|
|
2
|
+
|
|
3
|
+
Biblioteka i narzędzie do walidacji oraz konwersji wyciągów bankowych do formatu MT940 (wariant polski ZBP).
|
|
4
|
+
|
|
5
|
+
## Główne funkcjonalności
|
|
6
|
+
- **Walidacja**: Sprawdzanie spójności logicznej plików MT940 (sumy kontrolne sald i transakcji).
|
|
7
|
+
- **Konwersja**: Tworzenie plików MT940 z wyciągów CSV (obecnie wspiera mBank).
|
|
8
|
+
- **Eksport**: Konwersja MT940 do czytelnego formatu CSV.
|
|
9
|
+
- **Interfejsy**: Biblioteka Python, CLI oraz nowoczesne GUI (CustomTkinter).
|
|
10
|
+
|
|
11
|
+
## Instalacja
|
|
12
|
+
```bash
|
|
13
|
+
pip install mt940-py
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Użycie CLI
|
|
17
|
+
```bash
|
|
18
|
+
mt940-val gui # Uruchomienie interfejsu graficznego
|
|
19
|
+
mt940-val validate # Walidacja pliku
|
|
20
|
+
mt940-val convert # Konwersja CSV -> MT940
|
|
21
|
+
mt940-val export # Eksport MT940 -> CSV
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Rozwój
|
|
25
|
+
Projekt używa `black`, `ruff` i `pytest` do zapewnienia wysokiej jakości kodu.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mt940-py"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Narzędzie do walidacji i konwersji wyciągów bankowych do formatu MT940 (wariant ZBP)."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "TheUndefined", email = "undefine@aramin.net" }
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"mt-940>=4.30.0",
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
mt940-val = "mt940_py.main:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=61.0"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.black]
|
|
21
|
+
line-length = 120
|
|
22
|
+
target-version = ['py312']
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 120
|
|
26
|
+
target-version = "py312"
|
|
27
|
+
|
|
28
|
+
[tool.mypy]
|
|
29
|
+
python_version = "3.12"
|
|
30
|
+
strict = true
|
mt940_py-0.1.5/setup.cfg
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import datetime
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
class MT940Converter:
|
|
8
|
+
"""Konwerter plików CSV (mBank) do formatu MT940 (ZBP) zgodnego z mBank/Insert."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, encoding: str = "cp1250") -> None:
|
|
11
|
+
self.encoding = encoding
|
|
12
|
+
|
|
13
|
+
def _clean_amount_str(self, val: str) -> str:
|
|
14
|
+
if not val:
|
|
15
|
+
return "0.0"
|
|
16
|
+
cleaned = val.replace(" PLN", "").replace(" ", "").replace(",", ".").replace("\xa0", "")
|
|
17
|
+
cleaned = re.sub(r"[^\d.-]", "", cleaned)
|
|
18
|
+
return cleaned
|
|
19
|
+
|
|
20
|
+
def _format_amount(self, amount: float) -> str:
|
|
21
|
+
return f"{abs(amount):.2f}".replace(".", ",")
|
|
22
|
+
|
|
23
|
+
def _format_date(self, date_str: str) -> str:
|
|
24
|
+
try:
|
|
25
|
+
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
|
|
26
|
+
return dt.strftime("%y%m%d")
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
def _wrap_text(self, text: str, length: int) -> List[str]:
|
|
31
|
+
if not text:
|
|
32
|
+
return []
|
|
33
|
+
return [text[i : i + length] for i in range(0, len(text), length)]
|
|
34
|
+
|
|
35
|
+
def convert(self, csv_content: str) -> str:
|
|
36
|
+
lines = [line.strip() for line in csv_content.splitlines()]
|
|
37
|
+
header_index = -1
|
|
38
|
+
for i, line in enumerate(lines):
|
|
39
|
+
if "#Data księgowania;" in line:
|
|
40
|
+
header_index = i
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
if header_index == -1:
|
|
44
|
+
raise ValueError("Nie znaleziono nagłówka transakcji w pliku CSV.")
|
|
45
|
+
|
|
46
|
+
account_number = ""
|
|
47
|
+
initial_balance = 0.0
|
|
48
|
+
currency = "PLN"
|
|
49
|
+
statement_date = ""
|
|
50
|
+
|
|
51
|
+
for i, line in enumerate(lines[:header_index]):
|
|
52
|
+
if "#Numer rachunku" in line:
|
|
53
|
+
content = line
|
|
54
|
+
if i + 1 < header_index and (len(line.split(";")) <= 1 or not line.split(";")[1].strip()):
|
|
55
|
+
content = lines[i + 1]
|
|
56
|
+
match = re.search(r"(\d[\s\d]{20,})", content)
|
|
57
|
+
if match:
|
|
58
|
+
account_number = re.sub(r"[^\d]", "", match.group(1))
|
|
59
|
+
|
|
60
|
+
if "#Saldo początkowe" in line:
|
|
61
|
+
content = line
|
|
62
|
+
if i + 1 < header_index and (len(line.split(";")) <= 1 or not line.split(";")[1].strip()):
|
|
63
|
+
content = lines[i + 1]
|
|
64
|
+
try:
|
|
65
|
+
parts = content.split(";")
|
|
66
|
+
val_str = self._clean_amount_str(parts[1] if len(parts) > 1 else parts[0])
|
|
67
|
+
initial_balance = float(val_str)
|
|
68
|
+
except (IndexError, ValueError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
f = io.StringIO("\n".join(lines[header_index:]))
|
|
72
|
+
reader = csv.DictReader(f, delimiter=";")
|
|
73
|
+
|
|
74
|
+
# Przygotowanie transakcji do późniejszego użycia (potrzebujemy daty pierwszej transakcji)
|
|
75
|
+
rows = []
|
|
76
|
+
for row in reader:
|
|
77
|
+
raw_date = row.get("#Data księgowania")
|
|
78
|
+
if raw_date and re.match(r"\d{4}-\d{2}-\d{2}", raw_date):
|
|
79
|
+
rows.append(row)
|
|
80
|
+
|
|
81
|
+
if rows and not statement_date:
|
|
82
|
+
statement_date = self._format_date(rows[0]["#Data księgowania"])
|
|
83
|
+
else:
|
|
84
|
+
statement_date = datetime.datetime.now().strftime("%y%m%d")
|
|
85
|
+
|
|
86
|
+
output = []
|
|
87
|
+
# :20:
|
|
88
|
+
output.append(":20:MT940")
|
|
89
|
+
|
|
90
|
+
# :25:
|
|
91
|
+
if account_number:
|
|
92
|
+
output.append(f":25:/PL{account_number}")
|
|
93
|
+
else:
|
|
94
|
+
output.append(":25:/UNKNOWN_ACCOUNT")
|
|
95
|
+
|
|
96
|
+
# :28C: Numer wyciągu (mBank używa daty YYMMDD)
|
|
97
|
+
output.append(f":28C:{statement_date}")
|
|
98
|
+
|
|
99
|
+
# :60F: Saldo początkowe
|
|
100
|
+
sign_60 = "C" if initial_balance >= 0 else "D"
|
|
101
|
+
output.append(f":60F:{sign_60}{statement_date}{currency}{self._format_amount(initial_balance)}")
|
|
102
|
+
|
|
103
|
+
current_balance = initial_balance
|
|
104
|
+
|
|
105
|
+
for idx, row in enumerate(rows):
|
|
106
|
+
date_val = self._format_date(row["#Data księgowania"])
|
|
107
|
+
amount = float(self._clean_amount_str(row.get("#Kwota", "0")))
|
|
108
|
+
current_balance += amount
|
|
109
|
+
|
|
110
|
+
# :61: Linia transakcji
|
|
111
|
+
# Dodajemy numer referencyjny na końcu (np. SD95 + numer kolejny)
|
|
112
|
+
sign_61 = "C" if amount >= 0 else "D"
|
|
113
|
+
ref_num = f"{idx+1:011d}"
|
|
114
|
+
output.append(f":61:{date_val}{date_val[2:]}{sign_61}{self._format_amount(amount)}SD95{ref_num}")
|
|
115
|
+
|
|
116
|
+
# :86: Struktura mBanku (podwójny tag :86: i brak :86: w kolejnych liniach pola)
|
|
117
|
+
output.append(":86:SD95")
|
|
118
|
+
|
|
119
|
+
operation_type = row.get("#Opis operacji", "")
|
|
120
|
+
title = row.get("#Tytuł", "")
|
|
121
|
+
|
|
122
|
+
# Budujemy linię 2 pola 86
|
|
123
|
+
details_line2 = f"SD95~00BD95{operation_type}"
|
|
124
|
+
# Kolejne linie tytułu i danych
|
|
125
|
+
details_remaining = []
|
|
126
|
+
|
|
127
|
+
title_parts = self._wrap_text(title, 27)
|
|
128
|
+
for i, part in enumerate(title_parts[:8]):
|
|
129
|
+
details_remaining.append(f"~{20+i}{part}")
|
|
130
|
+
|
|
131
|
+
contractor = row.get("#Nadawca/Odbiorca", "")
|
|
132
|
+
contractor_parts = self._wrap_text(contractor, 35)
|
|
133
|
+
if len(contractor_parts) > 0:
|
|
134
|
+
details_remaining.append(f"~32{contractor_parts[0]}")
|
|
135
|
+
if len(contractor_parts) > 1:
|
|
136
|
+
details_remaining.append(f"~33{contractor_parts[1]}")
|
|
137
|
+
|
|
138
|
+
acc = row.get("#Numer konta", "").replace("'", "").strip()
|
|
139
|
+
if acc:
|
|
140
|
+
acc_clean = re.sub(r"[^\d]", "", acc)
|
|
141
|
+
if acc_clean:
|
|
142
|
+
details_remaining.append(f"~31{acc_clean}")
|
|
143
|
+
details_remaining.append(f"~38PL{acc_clean}")
|
|
144
|
+
|
|
145
|
+
# Składamy wszystko w całość zgodnie z formatem mBanku
|
|
146
|
+
# Pierwsza linia po :86: (druga w sumie)
|
|
147
|
+
raw_full_86 = "".join(details_remaining)
|
|
148
|
+
wrapped_86 = self._wrap_text(raw_full_86, 65)
|
|
149
|
+
|
|
150
|
+
output.append(f":86:{details_line2}")
|
|
151
|
+
for line in wrapped_86:
|
|
152
|
+
output.append(line)
|
|
153
|
+
|
|
154
|
+
# :62F:
|
|
155
|
+
sign_62 = "C" if current_balance >= 0 else "D"
|
|
156
|
+
# Data salda końcowego z ostatniej transakcji lub dzisiejsza
|
|
157
|
+
last_date = self._format_date(rows[-1]["#Data księgowania"]) if rows else statement_date
|
|
158
|
+
output.append(f":62F:{sign_62}{last_date}{currency}{self._format_amount(current_balance)}")
|
|
159
|
+
|
|
160
|
+
# Dodajemy znak końca pliku (opcjonalnie, ale mBank często go nie ma, SWIFT wymaga tylko pól)
|
|
161
|
+
# Łączymy używając CRLF
|
|
162
|
+
return "\r\n".join(output) + "\r\n"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
import mt940
|
|
5
|
+
from mt940_py.validator import MT940Validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MT940ToCSVExporter:
|
|
9
|
+
"""Eksporter plików MT940 do formatu CSV."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.validator = MT940Validator()
|
|
13
|
+
|
|
14
|
+
def _clean_text(self, text: str) -> str:
|
|
15
|
+
"""Usuwa znaki nowej linii i nadmiarowe spacje."""
|
|
16
|
+
if not text:
|
|
17
|
+
return ""
|
|
18
|
+
# Zamieniamy nową linię na spację
|
|
19
|
+
cleaned = text.replace("\n", " ").replace("\r", " ")
|
|
20
|
+
# Usuwamy wielokrotne spacje
|
|
21
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
22
|
+
return cleaned.strip()
|
|
23
|
+
|
|
24
|
+
def export(self, mt940_path: str) -> str:
|
|
25
|
+
statements = mt940.parse(mt940_path)
|
|
26
|
+
|
|
27
|
+
output = io.StringIO()
|
|
28
|
+
fieldnames = [
|
|
29
|
+
"Data księgowania",
|
|
30
|
+
"Kwota",
|
|
31
|
+
"Waluta",
|
|
32
|
+
"Typ",
|
|
33
|
+
"Kontrahent",
|
|
34
|
+
"Rachunek kontrahenta",
|
|
35
|
+
"Tytuł",
|
|
36
|
+
"Szczegóły RAW",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=";")
|
|
40
|
+
writer.writeheader()
|
|
41
|
+
|
|
42
|
+
if hasattr(statements, "data"):
|
|
43
|
+
statements_list = [statements]
|
|
44
|
+
else:
|
|
45
|
+
statements_list = statements
|
|
46
|
+
|
|
47
|
+
for statement in statements_list:
|
|
48
|
+
for transaction in statement:
|
|
49
|
+
data = transaction.data
|
|
50
|
+
raw_86 = data.get("transaction_details", "")
|
|
51
|
+
parsed_86 = self.validator.parse_field_86(raw_86)
|
|
52
|
+
|
|
53
|
+
title_parts = []
|
|
54
|
+
for i in range(20, 28):
|
|
55
|
+
val = parsed_86.get(str(i))
|
|
56
|
+
if val:
|
|
57
|
+
title_parts.append(val)
|
|
58
|
+
|
|
59
|
+
contractor = f"{parsed_86.get('32', '')} {parsed_86.get('33', '')}".strip()
|
|
60
|
+
acc = parsed_86.get("31", parsed_86.get("38", ""))
|
|
61
|
+
|
|
62
|
+
amount = self.validator.get_amount_value(data.get("amount"))
|
|
63
|
+
|
|
64
|
+
writer.writerow(
|
|
65
|
+
{
|
|
66
|
+
"Data księgowania": data.get("date"),
|
|
67
|
+
"Kwota": f"{amount:.2f}",
|
|
68
|
+
"Waluta": data.get("currency"),
|
|
69
|
+
"Typ": data.get("status"),
|
|
70
|
+
"Kontrahent": self._clean_text(contractor),
|
|
71
|
+
"Rachunek kontrahenta": self._clean_text(acc),
|
|
72
|
+
"Tytuł": self._clean_text(" ".join(title_parts)),
|
|
73
|
+
"Szczegóły RAW": self._clean_text(raw_86),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return output.getvalue()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import filedialog, messagebox
|
|
4
|
+
import customtkinter as ctk
|
|
5
|
+
from mt940_py.converter import MT940Converter
|
|
6
|
+
from mt940_py.validator import MT940Validator
|
|
7
|
+
|
|
8
|
+
# Ustawienia wyglądu
|
|
9
|
+
ctk.set_appearance_mode("System")
|
|
10
|
+
ctk.set_default_color_theme("blue")
|
|
11
|
+
|
|
12
|
+
class MT940App(ctk.CTk):
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
self.title("MT940-py: Konwerter i Walidator")
|
|
17
|
+
self.geometry("600x450")
|
|
18
|
+
|
|
19
|
+
self.converter = MT940Converter()
|
|
20
|
+
self.validator = MT940Validator()
|
|
21
|
+
|
|
22
|
+
# Układ siatki
|
|
23
|
+
self.grid_columnconfigure(0, weight=1)
|
|
24
|
+
self.grid_rowconfigure(3, weight=1)
|
|
25
|
+
|
|
26
|
+
# Nagłówek
|
|
27
|
+
self.label = ctk.CTkLabel(self, text="Konwerter mBank CSV -> MT940", font=ctk.CTkFont(size=20, weight="bold"))
|
|
28
|
+
self.label.grid(row=0, column=0, padx=20, pady=(20, 10))
|
|
29
|
+
|
|
30
|
+
# Przyciski akcji
|
|
31
|
+
self.button_frame = ctk.CTkFrame(self)
|
|
32
|
+
self.button_frame.grid(row=1, column=0, padx=20, pady=10, sticky="nsew")
|
|
33
|
+
self.button_frame.grid_columnconfigure((0, 1), weight=1)
|
|
34
|
+
|
|
35
|
+
self.select_button = ctk.CTkButton(self.button_frame, text="Wybierz plik CSV (mBank)", command=self.select_file)
|
|
36
|
+
self.select_button.grid(row=0, column=0, padx=10, pady=10)
|
|
37
|
+
|
|
38
|
+
self.val_button = ctk.CTkButton(self.button_frame, text="Waliduj plik MT940", command=self.validate_mt940, fg_color="gray")
|
|
39
|
+
self.val_button.grid(row=0, column=1, padx=10, pady=10)
|
|
40
|
+
|
|
41
|
+
# Status pliku
|
|
42
|
+
self.status_label = ctk.CTkLabel(self, text="Nie wybrano pliku", font=ctk.CTkFont(slant="italic"))
|
|
43
|
+
self.status_label.grid(row=2, column=0, padx=20, pady=5)
|
|
44
|
+
|
|
45
|
+
# Logi / Wyniki
|
|
46
|
+
self.textbox = ctk.CTkTextbox(self, width=560)
|
|
47
|
+
self.textbox.grid(row=3, column=0, padx=20, pady=10, sticky="nsew")
|
|
48
|
+
self.textbox.insert("0.0", "System gotowy.\n\n")
|
|
49
|
+
|
|
50
|
+
def log(self, message: str):
|
|
51
|
+
self.textbox.insert("end", f"{message}\n")
|
|
52
|
+
self.textbox.see("end")
|
|
53
|
+
|
|
54
|
+
def select_file(self):
|
|
55
|
+
file_path = filedialog.askopenfilename(
|
|
56
|
+
title="Wybierz plik mBank CSV",
|
|
57
|
+
filetypes=[("Pliki CSV", "*.csv"), ("Wszystkie pliki", "*.*")]
|
|
58
|
+
)
|
|
59
|
+
if file_path:
|
|
60
|
+
self.status_label.configure(text=f"Wybrano: {os.path.basename(file_path)}")
|
|
61
|
+
self.process_conversion(file_path)
|
|
62
|
+
|
|
63
|
+
def process_conversion(self, input_path: str):
|
|
64
|
+
output_path = filedialog.asksaveasfilename(
|
|
65
|
+
title="Zapisz jako MT940",
|
|
66
|
+
defaultextension=".txt",
|
|
67
|
+
initialfile=os.path.basename(input_path).replace(".csv", ".txt"),
|
|
68
|
+
filetypes=[("Pliki tekstowe", "*.txt"), ("Pliki MT940", "*.sta"), ("Wszystkie pliki", "*.*")]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not output_path:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
self.log(f"Rozpoczynam konwersję: {os.path.basename(input_path)}...")
|
|
76
|
+
with open(input_path, "r", encoding="cp1250", errors="replace") as f:
|
|
77
|
+
content = f.read()
|
|
78
|
+
|
|
79
|
+
mt940_content = self.converter.convert(content)
|
|
80
|
+
# Zapisujemy jako UTF-8 z BOM (utf-8-sig)
|
|
81
|
+
with open(output_path, "w", encoding="utf-8-sig") as f:
|
|
82
|
+
f.write(mt940_content)
|
|
83
|
+
|
|
84
|
+
self.log(f"SUCCESS: Zapisano do {output_path}")
|
|
85
|
+
val_results = self.validator.validate_file(output_path)
|
|
86
|
+
if val_results["is_valid"]:
|
|
87
|
+
self.log(f"Walidacja OK: {val_results['statements_count']} wyciągów.")
|
|
88
|
+
messagebox.showinfo("Sukces", "Konwersja zakończona pomyślnie!")
|
|
89
|
+
else:
|
|
90
|
+
self.log("BŁĄD WALIDACJI wygenerowanego pliku!")
|
|
91
|
+
messagebox.showwarning("Uwaga", "Plik wygenerowany, ale zawiera błędy logiczne.")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
self.log(f"ERROR: {str(e)}")
|
|
94
|
+
messagebox.showerror("Błąd", str(e))
|
|
95
|
+
|
|
96
|
+
def validate_mt940(self):
|
|
97
|
+
file_path = filedialog.askopenfilename(
|
|
98
|
+
title="Wybierz plik MT940",
|
|
99
|
+
filetypes=[("Pliki tekstowe", "*.txt"), ("Pliki MT940", "*.sta"), ("Wszystkie pliki", "*.*")]
|
|
100
|
+
)
|
|
101
|
+
if file_path:
|
|
102
|
+
val_results = self.validator.validate_file(file_path)
|
|
103
|
+
if val_results["is_valid"]:
|
|
104
|
+
self.log(f"SUCCESS: Plik {os.path.basename(file_path)} poprawny.")
|
|
105
|
+
messagebox.showinfo("OK", "Plik jest poprawny.")
|
|
106
|
+
else:
|
|
107
|
+
self.log(f"FAILURE: Plik {os.path.basename(file_path)} błędny.")
|
|
108
|
+
messagebox.showerror("Błąd", "Plik zawiera błędy.")
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
app = MT940App()
|
|
112
|
+
app.mainloop()
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
# Fix for libraries that expect sys.stdout/stderr to be present (like mt-940)
|
|
5
|
+
# especially when compiled with --noconsole
|
|
6
|
+
if sys.stdout is None:
|
|
7
|
+
sys.stdout = io.StringIO()
|
|
8
|
+
if sys.stderr is None:
|
|
9
|
+
sys.stderr = io.StringIO()
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
from mt940_py.validator import MT940Validator
|
|
14
|
+
from mt940_py.converter import MT940Converter
|
|
15
|
+
from mt940_py.exporter import MT940ToCSVExporter
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
parser = argparse.ArgumentParser(description="Narzędzie MT940: CLI & GUI.")
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command", help="Komenda do wykonania")
|
|
20
|
+
|
|
21
|
+
# CLI commands
|
|
22
|
+
subparsers.add_parser("validate", help="Waliduje plik MT940").add_argument("file")
|
|
23
|
+
|
|
24
|
+
conv_parser = subparsers.add_parser("convert", help="Konwertuje CSV to MT940")
|
|
25
|
+
conv_parser.add_argument("input")
|
|
26
|
+
conv_parser.add_argument("output")
|
|
27
|
+
|
|
28
|
+
exp_parser = subparsers.add_parser("export", help="Eksportuje MT940 to CSV")
|
|
29
|
+
exp_parser.add_argument("input")
|
|
30
|
+
exp_parser.add_argument("output")
|
|
31
|
+
|
|
32
|
+
# GUI command
|
|
33
|
+
subparsers.add_parser("gui", help="Uruchamia interfejs graficzny")
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
if args.command == "gui":
|
|
38
|
+
from mt940_py.gui import main as run_gui
|
|
39
|
+
run_gui()
|
|
40
|
+
elif args.command == "validate":
|
|
41
|
+
validator = MT940Validator()
|
|
42
|
+
results = validator.validate_file(args.file)
|
|
43
|
+
if results["is_valid"]:
|
|
44
|
+
print(f"SUCCESS: {results['statements_count']} wyciągów, {results['transactions_count']} transakcji.")
|
|
45
|
+
else:
|
|
46
|
+
print("FAILURE: Błędy:")
|
|
47
|
+
for err in results["errors"]: print(f" - {err}")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
elif args.command == "convert":
|
|
50
|
+
try:
|
|
51
|
+
with open(args.input, 'r', encoding='cp1250', errors='replace') as f:
|
|
52
|
+
mt940_res = MT940Converter().convert(f.read())
|
|
53
|
+
# Zapisujemy jako UTF-8 z BOM (utf-8-sig)
|
|
54
|
+
with open(args.output, 'w', encoding='utf-8-sig') as f:
|
|
55
|
+
f.write(mt940_res)
|
|
56
|
+
print(f"SUCCESS: {args.output}")
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"ERROR: {e}")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
elif args.command == "export":
|
|
61
|
+
try:
|
|
62
|
+
csv_res = MT940ToCSVExporter().export(args.input)
|
|
63
|
+
with open(args.output, 'w', encoding='utf-8') as f:
|
|
64
|
+
f.write(csv_res)
|
|
65
|
+
print(f"SUCCESS: {args.output}")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"ERROR: {e}")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
else:
|
|
70
|
+
# Default to GUI if no command
|
|
71
|
+
from mt940_py.gui import main as run_gui
|
|
72
|
+
run_gui()
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, Any, List
|
|
3
|
+
import mt940 # type: ignore
|
|
4
|
+
|
|
5
|
+
class MT940Validator:
|
|
6
|
+
"""Walidator plików MT940 z uwzględnieniem polskich rozszerzeń ZBP."""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
def parse_field_86(self, raw_details: str) -> Dict[str, str]:
|
|
12
|
+
if not raw_details:
|
|
13
|
+
return {}
|
|
14
|
+
parts = re.split(r"~(\d{2})", raw_details)
|
|
15
|
+
parsed: Dict[str, str] = {}
|
|
16
|
+
if len(parts) > 1:
|
|
17
|
+
for i in range(1, len(parts), 2):
|
|
18
|
+
tag = parts[i]
|
|
19
|
+
value = parts[i + 1].strip() if i + 1 < len(parts) else ""
|
|
20
|
+
if tag in parsed:
|
|
21
|
+
parsed[tag] += " " + value
|
|
22
|
+
else:
|
|
23
|
+
parsed[tag] = value
|
|
24
|
+
return parsed
|
|
25
|
+
|
|
26
|
+
def get_amount_value(self, obj: Any) -> float:
|
|
27
|
+
if obj is None:
|
|
28
|
+
return 0.0
|
|
29
|
+
if hasattr(obj, "amount") and hasattr(obj.amount, "amount"):
|
|
30
|
+
return float(obj.amount.amount)
|
|
31
|
+
if hasattr(obj, "amount"):
|
|
32
|
+
return float(obj.amount)
|
|
33
|
+
try:
|
|
34
|
+
return float(obj)
|
|
35
|
+
except Exception:
|
|
36
|
+
return 0.0
|
|
37
|
+
|
|
38
|
+
def validate_file(self, file_path: str) -> Dict[str, Any]:
|
|
39
|
+
results: Dict[str, Any] = {
|
|
40
|
+
"is_valid": True,
|
|
41
|
+
"errors": [],
|
|
42
|
+
"statements_count": 0,
|
|
43
|
+
"transactions_count": 0,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = mt940.parse(file_path)
|
|
48
|
+
|
|
49
|
+
if hasattr(data, "data"):
|
|
50
|
+
statements = [data]
|
|
51
|
+
else:
|
|
52
|
+
statements = data
|
|
53
|
+
|
|
54
|
+
results["statements_count"] = len(statements)
|
|
55
|
+
|
|
56
|
+
for statement in statements:
|
|
57
|
+
opening = statement.data.get("final_opening_balance")
|
|
58
|
+
closing = statement.data.get("final_closing_balance")
|
|
59
|
+
|
|
60
|
+
initial_val = self.get_amount_value(opening)
|
|
61
|
+
expected_final = self.get_amount_value(closing)
|
|
62
|
+
|
|
63
|
+
actual_sum = initial_val
|
|
64
|
+
|
|
65
|
+
for transaction in statement:
|
|
66
|
+
results["transactions_count"] += 1
|
|
67
|
+
amount = transaction.data.get("amount")
|
|
68
|
+
actual_sum += self.get_amount_value(amount)
|
|
69
|
+
|
|
70
|
+
raw_86 = transaction.data.get("transaction_details", "")
|
|
71
|
+
self.parse_field_86(raw_86)
|
|
72
|
+
|
|
73
|
+
if abs(actual_sum - expected_final) > 0.01:
|
|
74
|
+
results["is_valid"] = False
|
|
75
|
+
results["errors"].append(
|
|
76
|
+
f"Błąd sumy kontrolnej: Saldo pocz. ({initial_val:.2f}) + transakcje = {actual_sum:.2f}, "
|
|
77
|
+
f"a saldo końcowe w pliku to {expected_final:.2f}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
results["is_valid"] = False
|
|
82
|
+
results["errors"].append(f"Błąd krytyczny parsowania: {str(e)}")
|
|
83
|
+
|
|
84
|
+
return results
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/mt940_py/converter.py
|
|
4
|
+
src/mt940_py/exporter.py
|
|
5
|
+
src/mt940_py/gui.py
|
|
6
|
+
src/mt940_py/main.py
|
|
7
|
+
src/mt940_py/validator.py
|
|
8
|
+
src/mt940_py.egg-info/PKG-INFO
|
|
9
|
+
src/mt940_py.egg-info/SOURCES.txt
|
|
10
|
+
src/mt940_py.egg-info/dependency_links.txt
|
|
11
|
+
src/mt940_py.egg-info/entry_points.txt
|
|
12
|
+
src/mt940_py.egg-info/requires.txt
|
|
13
|
+
src/mt940_py.egg-info/top_level.txt
|
|
14
|
+
tests/test_converter.py
|
|
15
|
+
tests/test_validator.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mt-940>=4.30.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mt940_py
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from mt940_py.converter import MT940Converter
|
|
3
|
+
|
|
4
|
+
def test_clean_amount_str():
|
|
5
|
+
conv = MT940Converter()
|
|
6
|
+
assert conv._clean_amount_str("1 234,56 PLN") == "1234.56"
|
|
7
|
+
assert conv._clean_amount_str("-75,82") == "-75.82"
|
|
8
|
+
|
|
9
|
+
def test_format_amount():
|
|
10
|
+
conv = MT940Converter()
|
|
11
|
+
assert conv._format_amount(1234.56) == "1234,56"
|
|
12
|
+
|
|
13
|
+
def test_format_date():
|
|
14
|
+
conv = MT940Converter()
|
|
15
|
+
assert conv._format_date("2026-01-10") == "260110"
|
|
16
|
+
|
|
17
|
+
def test_basic_conversion_structure():
|
|
18
|
+
# Bardziej realistyczny format mBanku
|
|
19
|
+
csv_content = (
|
|
20
|
+
"#Numer rachunku;PL12345678901234567890123456;\n"
|
|
21
|
+
"#Saldo początkowe;100,00 PLN;\n"
|
|
22
|
+
"#Data księgowania;#Data operacji;#Opis operacji;#Tytuł;#Nadawca/Odbiorca;#Numer konta;#Kwota;#Saldo po operacji;\n"
|
|
23
|
+
"2026-01-10;2026-01-10;OPIS;TYTUL;NADAWCA;112233;-10,00;90,00;\n"
|
|
24
|
+
)
|
|
25
|
+
conv = MT940Converter()
|
|
26
|
+
result = conv.convert(csv_content)
|
|
27
|
+
|
|
28
|
+
assert ":20:MT940" in result
|
|
29
|
+
assert "/PL12345678901234567890123456" in result
|
|
30
|
+
assert ":60F:C260110PLN100,00" in result
|
|
31
|
+
assert "D10,00" in result
|
|
32
|
+
assert ":62F:C260110PLN90,00" in result
|
|
33
|
+
assert "\r\n" in result
|
|
34
|
+
# Sprawdzenie podwójnego 86
|
|
35
|
+
assert ":86:SD95\r\n:86:SD95~00BD95OPIS" in result
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import os
|
|
3
|
+
from mt940_py.validator import MT940Validator
|
|
4
|
+
from mt940_py.converter import MT940Converter
|
|
5
|
+
|
|
6
|
+
def test_validator_success():
|
|
7
|
+
csv_content = (
|
|
8
|
+
"#Numer rachunku;1234567890\n"
|
|
9
|
+
"#Saldo początkowe;100,00\n"
|
|
10
|
+
"#Data księgowania;Tytuł;Kwota\n"
|
|
11
|
+
"2026-01-10;TEST;50,00\n"
|
|
12
|
+
)
|
|
13
|
+
conv = MT940Converter()
|
|
14
|
+
mt940_data = conv.convert(csv_content)
|
|
15
|
+
|
|
16
|
+
temp_path = "tests/temp_test.txt"
|
|
17
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
18
|
+
f.write(mt940_data)
|
|
19
|
+
|
|
20
|
+
validator = MT940Validator()
|
|
21
|
+
results = validator.validate_file(temp_path)
|
|
22
|
+
|
|
23
|
+
if os.path.exists(temp_path):
|
|
24
|
+
os.remove(temp_path)
|
|
25
|
+
|
|
26
|
+
assert results["is_valid"] is True
|
|
27
|
+
|
|
28
|
+
def test_validator_error():
|
|
29
|
+
bad_mt940 = (
|
|
30
|
+
":20:MT940\r\n"
|
|
31
|
+
":25:/PL123\r\n"
|
|
32
|
+
":60F:C260101PLN100,00\r\n"
|
|
33
|
+
":61:2601010101C50,00SD95\r\n"
|
|
34
|
+
":86:TEST\r\n"
|
|
35
|
+
":62F:C260101PLN200,00\r\n"
|
|
36
|
+
)
|
|
37
|
+
temp_path = "tests/temp_bad.txt"
|
|
38
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(bad_mt940)
|
|
40
|
+
|
|
41
|
+
validator = MT940Validator()
|
|
42
|
+
results = validator.validate_file(temp_path)
|
|
43
|
+
|
|
44
|
+
if os.path.exists(temp_path):
|
|
45
|
+
os.remove(temp_path)
|
|
46
|
+
|
|
47
|
+
assert results["is_valid"] is False
|