From 2fa5fbca3915ba555e3c7dbfe2902cc0dbf9c598 Mon Sep 17 00:00:00 2001 From: Sabo Sabev Date: Sat, 21 Mar 2026 16:11:47 +0200 Subject: [PATCH] First --- .gitignore | 28 ++ GIT_SETUP.md | 62 +++ ITD.ico | Bin 0 -> 36469 bytes ITD_desc.txt | 33 ++ README.md | 193 ++++++++ appsettings.example.json | 5 + build.bat | 49 ++ centered_messagebox.py | 88 ++++ db.py | 510 +++++++++++++++++++ db_check.py | 89 ++++ delete_123123_parking.py | 29 ++ itd_db.py | 527 ++++++++++++++++++++ itd_transport.log | 1 + main.py | 893 ++++++++++++++++++++++++++++++++++ make_icon.py | 40 ++ requirements.txt | 2 + reset_manual_cycle_closes.sql | 5 + schema.sql | 37 ++ start.bat | 26 + 19 files changed, 2617 insertions(+) create mode 100644 .gitignore create mode 100644 GIT_SETUP.md create mode 100644 ITD.ico create mode 100644 ITD_desc.txt create mode 100644 README.md create mode 100644 appsettings.example.json create mode 100644 build.bat create mode 100644 centered_messagebox.py create mode 100644 db.py create mode 100644 db_check.py create mode 100644 delete_123123_parking.py create mode 100644 itd_db.py create mode 100644 itd_transport.log create mode 100644 main.py create mode 100644 make_icon.py create mode 100644 requirements.txt create mode 100644 reset_manual_cycle_closes.sql create mode 100644 schema.sql create mode 100644 start.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78000be --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ + +# Build / PyInstaller +build/ +dist/ +*.spec + +# Локални настройки (пароли) – не комитвайте +appsettings.json +*appsettings.json + +# IDE / OS +.idea/ +.vscode/ +*.swp +Thumbs.db +.DS_Store + +# Опционално: ако пазите exe в проекта +# *.exe diff --git a/GIT_SETUP.md b/GIT_SETUP.md new file mode 100644 index 0000000..0b9f582 --- /dev/null +++ b/GIT_SETUP.md @@ -0,0 +1,62 @@ +# Локални копия + GitHub + +## Защо така +- Всеки компютър има **пълно локално копие** – няма споделена папка по LAN. +- **Build-вате локално** на машината, където пускате `build.bat`. +- Синхронизация чрез **Git** и **GitHub**. + +--- + +## Еднократна настройка + +### 1. Git в проекта (на първия компютър) + +В папката на проекта (където е `main.py`): + +```bat +git init +git add . +git commit -m "Първи комит - ITD Transport" +``` + +**Важно:** `appsettings.json` е в `.gitignore` – не се комитва (пароли). На всеки компютър копирайте `appsettings.example.json` като `appsettings.json` и попълнете реалните данни. + +### 2. Репозиторий в GitHub + +1. Влезте в [github.com](https://github.com), създайте **нов репозиторий** (New repository). +2. Име например: `ITD-desktop`. Не пипайте „Initialize with README“ ако вече имате локални файлове. +3. След създаване GitHub ще покаже команди – използвайте **„push an existing repository“**: + +```bat +git remote add origin https://github.com ВАШИЯ_ПОТРЕБИТЕЛ/ITD-desktop.git +git branch -M main +git push -u origin main +``` + +(Заменете URL с реалния от GitHub.) + +### 3. Втори компютър (локално копие) + +Клониране на същия проект: + +```bat +git clone https://github.com ВАШИЯ_ПОТРЕБИТЕЛ/ITD-desktop.git +cd ITD-desktop +``` + +Създайте `appsettings.json` (копие от `appsettings.example.json` с правилен connection string). След това можете да пускате приложението и **да build-вате локално** с `build.bat`. + +--- + +## Ежедневна работа + +- **Променили сте нещо:** + `git add .` → `git commit -m "Описание"` → `git push` + +- **На другия компютър искате последните промени:** + `git pull` + +- **Build локално:** + На машината, където искате .exe: отворете папката на проекта и стартирайте `build.bat`. Полученият exe е в `dist\ITD_Transport.exe`. + +Така работите с **локални копия** и **GitHub**, и build-вате локално на избраната машина. diff --git a/ITD.ico b/ITD.ico new file mode 100644 index 0000000000000000000000000000000000000000..f3af19cf125af05e08d63323a20ad753ed2a4ff3 GIT binary patch literal 36469 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μrX3O%XjOB@6;{t zm7b&0l9vUxoRQ7$GC91%WOhVVRhO23V%ffg!9SXahd+V`I>EZY*UOf_ciix`$|C|7 zozQfOgbo>o%`?j>{l6iaRK}rM&D<3{Y;%5krYyS^&0yb)a8NkDbDZ%Pyl` z&z`!TX*NQ5T%G4XlZ{SDG@VqbGtc)X4S#Udawqe&3Ky;5iAQy9gj)PPq)!VWAR=U0 zMpahic}C|07mEdcwIrtlV|e5%{(3RtZx=ZijU|K*tNjsfLy5Fkp~q&yP+(_b2xI6Y zbUM$cPnZ1Lk75Y;vE{tI{7vDl#nmU^2tIM|zw zT4oR0*b%!B&FxLYUW;oBfK7WyFLVd}9t?r0Q{FFI{_d>dPYxSuqZFF)1F>|Si=Eag z&cy*coTl^5y5&1n!?&x3_sYOgVabXDt!8L_NH#sBo=m8!lDt1)V^gM^7*@B)$lQzv zgD7uhp5L1^eD9#e3Y@ftRWNbY&OASOW$^nN0^$QfIyx74VuhbCEl=9OF9#E#KWw5f zaGWb&ua)*CL{*D1jj@m|5>0%Q8dNXXefNh`P3oF$artZC>(9)IXgG_m7x{^WB6zV%csi2)^pOpDky#}07k{u@onPl7w>Jcwm;;h z3#bn?UEpV5tXKtu4{#?}ey8>v7ZY$rFN|+)pN=OD>6^$1Oskyx#}nQ;TrtTnAS|N* z$jy!K@mv8Q^y~QRJ;nx&ryc@PwKvvtr|9_hq~pVS#rt_f69f&0rqigF2B8+u{UUHT zgIN|ha-O&HmbdecdwJlnFw|8+K0U-vj>zj7le(rZ9Azfk$3iJLbF927deb-)W#W`W z7ox-7TD+?$tBNekB-ZKvwO&5=-+GK_Vh&jx=wq?HP zSQY^@X*ei^X_e!wA=nHxImS8S+!?=Xts&2I@;t{m2MV3I4--(oWZg^KA#t=J{ADOihwbK$sBJT*1UDFpvv6XX1RR< zuu+3HUbfN-xf51uXoGN+1>URRcj~~qWjv_zq_Zs4VKaxV9YzCRt3BVFwR|uM+%G&6 z>#+okz$CYnWkEifQ5_tUPov5|EgX|f$Yo?pumAaSoqm_-+e3*1=AskMy`eM~pVyfB znBwpbMOA}|>Spje=K>4@Kn$wjv_YCVX0>Fw2(%ZLWv8sVJ|Eut5K3jSYItOR_aIU0`z2G7WPkiv`75 z7jx6C)$dX4`%P8b5!*h~XpOJ>w!8=q$qFR%)&;y8x?ST+`33i0Y8|+W>}_e!LK?V zpEi8evu!M=llFOGGh;00Cu4I6JEivq?dg#a$%YFFkKIja^SPU)uz5 z1~MC{opQJEOf#VgF@O7!^?bcp@pciYoX|=}V+$tJn(x{vlj95xEnjn<4~mxGsXE@y zJf%@O3@aRxmDsAr)CXkMjJm3sR5{Z;#(}#&)OVx=z8Ul;aTAnz2N%I%T#eR8nCTtd z><(F7V_i0~#rFvS=>giyo%eT zs|m&yYXP7d`k%o`2#8?&*(i55W-{&q3pXdfFj(gi39#cVyH)zi1z_ppiO3h~$Zd%h zQ*2d{RV8^<;mRo{E298_-Wxmk%ysMdZL3gZjxx{is??lNJzSAz=U7>-9gEjxoDpb9 z{%QLCUO|=l%JcK59pC?~1Do-lTrj1?h@s14I`bIEf3se!jL#5^I|1V|xVd2sw%HZ{ z>jNa>?_rrv3{0%&Zti)f3cOzi?&JZ2r}cqCly@uTurNqoq0TU~6~A}qfN!`%=47

KXR%F8TD3XoV3jCl24mC)29JzThO*Y1T|)& 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 +setlocal EnableDelayedExpansion +cd /d "%~dp0" + +echo ITD Transport - building portable EXE +echo. + +python -c "import PyInstaller" >nul 2>nul +if errorlevel 1 ( + echo PyInstaller not found. Install: pip install pyinstaller + pause + exit /b 1 +) + +if not exist "main.py" ( + echo Error: main.py not found in current folder. + pause + exit /b 1 +) + +python -m PyInstaller --onefile -w --name "ITD_Transport" ^ + --icon "ITD.ico" ^ + --add-data "schema.sql;." ^ + --hidden-import=pyodbc ^ + --hidden-import=qrcode ^ + --hidden-import=PIL ^ + --hidden-import=PIL.Image ^ + --hidden-import=PIL.ImageTk ^ + --hidden-import=itd_db ^ + --hidden-import=db_check ^ + --hidden-import=centered_messagebox ^ + main.py + +if errorlevel 1 ( + echo. + echo Build failed. + pause + exit /b 1 +) + +echo. +echo Done. EXE: dist\ITD_Transport.exe +echo. +echo For portable use: copy dist\ITD_Transport.exe, appsettings.json and ITD.ico +echo to a folder on the target machine. +echo. +pause +exit /b 0 diff --git a/centered_messagebox.py b/centered_messagebox.py new file mode 100644 index 0000000..77aebce --- /dev/null +++ b/centered_messagebox.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +""" +Диалози за съобщения, центрирани спрямо прозореца на приложението (parent), +а не спрямо физическия екран. +""" + +import tkinter as tk +from tkinter import ttk + + +def _center_on_parent(dialog, parent): + """Поставя диалога в центъра на parent (размер и позиция на приложението).""" + if parent is None: + return + dialog.update_idletasks() + parent.update_idletasks() + w = dialog.winfo_reqwidth() + h = dialog.winfo_reqheight() + rw = parent.winfo_width() + rh = parent.winfo_height() + rx = parent.winfo_rootx() + ry = parent.winfo_rooty() + x = rx + max(0, (rw - w) // 2) + y = ry + max(0, (rh - h) // 2) + dialog.geometry(f"+{x}+{y}") + + +def _dialog(parent, title, message, dialog_type="info", ask_yes_no=False): + """ + Показва модален диалог (Toplevel), центриран спрямо parent. + dialog_type: "info" | "warning" | "error" + ask_yes_no: ако True, бутони Да/Не и връща True/False. + """ + root = parent.winfo_toplevel() if parent else tk._default_root + d = tk.Toplevel(root) + d.title(title) + d.transient(parent or root) + d.resizable(False, False) + + # рамка с отстъп + f = ttk.Frame(d, padding=16) + f.pack(fill=tk.BOTH, expand=True) + + # иконка + текст (опционално различни цветове за error/warning) + msg_label = ttk.Label(f, text=message, wraplength=400, justify=tk.LEFT) + msg_label.pack(anchor=tk.W, fill=tk.X, pady=(0, 12)) + + result = [None] + + def ok(): + result[0] = True + d.destroy() + + def no(): + result[0] = False + d.destroy() + + btn_frame = ttk.Frame(f) + btn_frame.pack(anchor=tk.E) + if ask_yes_no: + ttk.Button(btn_frame, text="Да", command=ok).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Не", command=no).pack(side=tk.LEFT, padx=4) + else: + ttk.Button(btn_frame, text="OK", command=ok).pack(side=tk.LEFT, padx=4) + + d.protocol("WM_DELETE_WINDOW", no if ask_yes_no else ok) + if parent: + d.after_idle(lambda: _center_on_parent(d, parent)) + d.grab_set() + d.focus_set() + d.wait_window() + return result[0] if ask_yes_no else None + + +def showinfo(title, message, parent=None): + _dialog(parent, title, message, "info", ask_yes_no=False) + + +def showwarning(title, message, parent=None): + _dialog(parent, title, message, "warning", ask_yes_no=False) + + +def showerror(title, message, parent=None): + _dialog(parent, title, message, "error", ask_yes_no=False) + + +def askyesno(title, message, parent=None): + return _dialog(parent, title, message, "info", ask_yes_no=True) is True diff --git a/db.py b/db.py new file mode 100644 index 0000000..110d653 --- /dev/null +++ b/db.py @@ -0,0 +1,510 @@ +# -*- coding: utf-8 -*- +"""ITD - общ модул за връзка и зареждане на данни от базата.""" + +import json +import os +from datetime import datetime, timezone + +try: + import pyodbc +except ImportError: + pyodbc = None + + +_LAST_APPSETTINGS_PATH = None +_LAST_CONNECTION_STRING_REDACTED = None + + +def get_last_appsettings_path(): + """Връща пълния път към последно намерения appsettings.json (или None).""" + return _LAST_APPSETTINGS_PATH + + +def get_last_connection_string_redacted(): + """Връща последно използвания connection string с маскирани пароли (или None).""" + return _LAST_CONNECTION_STRING_REDACTED + + +def _redact_conn_str(s: str) -> str: + if not s: + return s + redacted_parts = [] + for part in str(s).split(";"): + p = part.strip() + if not p: + continue + if "=" not in p: + redacted_parts.append(p) + continue + k, v = p.split("=", 1) + kl = k.strip().lower() + if kl in ("pwd", "password"): + redacted_parts.append(f"{k}=***") + else: + redacted_parts.append(f"{k}={v}") + return ";".join(redacted_parts) + ";" + + +def load_connection_string(): + """Зарежда connection string от appsettings.json.""" + import sys + + global _LAST_APPSETTINGS_PATH + global _LAST_CONNECTION_STRING_REDACTED + + paths = [] + if getattr(sys, "frozen", False): + paths.append(os.path.join(os.path.dirname(sys.executable), "appsettings.json")) + paths.extend(("appsettings.json", os.path.join(os.path.dirname(__file__), "appsettings.json"))) + for path in paths: + if os.path.isfile(path): + _LAST_APPSETTINGS_PATH = os.path.abspath(path) + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + cs = cfg.get("ConnectionStrings", {}) + sql = cs.get("SqlServer", "") + if sql: + parts = {} + for part in sql.split(";"): + part = part.strip() + if not part or "=" not in part: + continue + k, v = part.split("=", 1) + parts[k.strip().lower()] = v.strip() + driver = "ODBC Driver 17 for SQL Server" + conn_str = ( + f"DRIVER={{{driver}}};" + f"SERVER={parts.get('server', '')};" + f"DATABASE={parts.get('database', '')};" + f"UID={parts.get('user id', '')};" + f"PWD={parts.get('password', '')};" + + ("TrustServerCertificate=yes;" if parts.get("trustservercertificate", "").lower() == "true" else "") + ) + _LAST_CONNECTION_STRING_REDACTED = _redact_conn_str(conn_str) + return conn_str + + if cs.get("Odbc"): + conn_str = cs["Odbc"] + _LAST_CONNECTION_STRING_REDACTED = _redact_conn_str(conn_str) + return conn_str + + raise ValueError("В appsettings.json липсва ConnectionStrings:SqlServer или Odbc") + tried = [os.path.abspath(p) for p in paths] + raise FileNotFoundError("Не е намерен appsettings.json. Пробвани пътища:\n- " + "\n- ".join(tried)) + + +def get_connection(): + """Отваря връзка към SQL Server (autocommit).""" + if pyodbc is None: + raise RuntimeError("Инсталирайте pyodbc: pip install pyodbc") + conn = pyodbc.connect(load_connection_string()) + conn.autocommit = True + return conn + + +def _parse_iso(s): + if s is None: + return None + if hasattr(s, "isoformat"): + return s + try: + return datetime.fromisoformat(str(s).replace("Z", "+00:00")) + except Exception: + return None + + +def _utc_to_local(dt): + """ + Преобразува datetime от БД (съхранен като UTC чрез SYSUTCDATETIME()) в локално време за показване. + Така няма разминаване с 2 часа (UTC+2) при потребители в България. + """ + if dt is None: + return None + if hasattr(dt, "tzinfo") and dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + # naive datetime от pyodbc = считаме за UTC + return dt.replace(tzinfo=timezone.utc).astimezone().replace(tzinfo=None) + + +def utc_to_local_for_display(dt): + """Публична функция за показване на дата от БД в локално време (за main.py и др.).""" + return _utc_to_local(dt) + + +def _format_duration(minutes): + if minutes is None: + return "" + if minutes < 60: + return f"{int(minutes)}м" + h, m = divmod(int(minutes), 60) + return f"{h}ч {m}м" + + +def load_cycles(conn, limit=200): + """ + Зарежда последните цикли от ITD_Cycles. + Всеки ред = един цикъл с 5 полета за време (Post1At..Post5At) + ServiceClosed. + """ + cursor = conn.cursor() + cursor.execute(""" + SELECT y.Id, y.CardId, y.CycleNo, y.Post1At, y.Post2At, y.Post3At, y.Post4At, y.Post5At, + y.ServiceClosed, y.ServiceClosedAt, + c.Code, c.DisplayText, c.IsActive, c.UpdatedAt + FROM dbo.ITD_Cycles y + JOIN dbo.ITD_Cards c ON c.Id = y.CardId + ORDER BY y.Post1At DESC + """) + rows = cursor.fetchall() + + result = [] + for r in rows: + cycle_id = r.Id + card_id = r.CardId + t1 = _utc_to_local(_parse_iso(r.Post1At)) + t2 = _utc_to_local(_parse_iso(r.Post2At)) + t3 = _utc_to_local(_parse_iso(r.Post3At)) + t4 = _utc_to_local(_parse_iso(r.Post4At)) + t5 = _utc_to_local(_parse_iso(r.Post5At)) + service_closed = bool(getattr(r, "ServiceClosed", False)) + service_closed_at = getattr(r, "ServiceClosedAt", None) + manual_dt = _utc_to_local(_parse_iso(service_closed_at)) if service_closed_at else None + + def _fmt_dt(dt): + return dt.strftime("%d.%m %H:%M") if dt else "" + + post_times = { + 1: _fmt_dt(t1), + 2: _fmt_dt(t2), + 3: _fmt_dt(t3), + 4: _fmt_dt(t4), + 5: _fmt_dt(t5), + } + + post_durations = {1: "", 2: "", 3: "", 4: "", 5: ""} + if t1 and t2: + post_durations[1] = _format_duration((t2 - t1).total_seconds() / 60) + if t2 and t3: + post_durations[2] = _format_duration((t3 - t2).total_seconds() / 60) + if t3 and t4: + post_durations[3] = _format_duration((t4 - t3).total_seconds() / 60) + if t4 and t5: + post_durations[4] = _format_duration((t5 - t4).total_seconds() / 60) + + reg_dt = t2 or t1 + reg_str = reg_dt.strftime("%d.%m.%Y %H:%M") if reg_dt else "" + + if t1 and t2: + time_reg_entry = _format_duration((t2 - t1).total_seconds() / 60) + else: + time_reg_entry = "" + + parts = [] + if t2 and t3: + parts.append(f"2→3: {_format_duration((t3 - t2).total_seconds() / 60)}") + if t3 and t4: + parts.append(f"3→4: {_format_duration((t4 - t3).total_seconds() / 60)}") + if t4 and t5: + parts.append(f"4→5: {_format_duration((t5 - t4).total_seconds() / 60)}") + time_between = " | ".join(parts) if parts else "" + + exit_dt = t5 or manual_dt + service_exit = bool(service_closed and not t5) + if not exit_dt: + exit_str = "" + elif service_closed and not t5: + # Служебно затваряне – запазваме сегашния текст (дата/час) + exit_str = manual_dt.strftime("%d.%m.%Y %H:%M") if manual_dt else "Служебен изход" + if manual_dt: + exit_str = f"Служебен изход - {exit_str}" + else: + # Нормално напускане (има пост 5) – показваме общ престой от пост 1 до пост 5 + if t1 and t5: + total_mins = (t5 - t1).total_seconds() / 60 + exit_str = _format_duration(total_mins) + else: + exit_str = exit_dt.strftime("%d.%m.%Y %H:%M") + + can_close = t5 is None and not service_closed + cycle_started_raw = r.Post1At + cycle_started_norm = _normalize_cycle_started_at(cycle_started_raw) + + result.append({ + "cycle_id": cycle_id, + "code": getattr(r, "Code", None) or "", + "display_text": r.DisplayText, + "reg_datetime": reg_str, + "time_reg_entry": time_reg_entry, + "time_between_posts": time_between, + "exit_datetime": exit_str, + "post1": f"{post_times[1]} {post_durations[1]}", + "post2": f"{post_times[2]} {post_durations[2]}", + "post3": f"{post_times[3]} {post_durations[3]}", + "post4": f"{post_times[4]} {post_durations[4]}", + "post5": f"{post_times[5]} {post_durations[5]}", + "service_exit": service_exit, + "can_close": can_close, + "card_id": card_id, + "is_active": bool(getattr(r, "IsActive", 1)), + "card_updated_at": getattr(r, "UpdatedAt", None), + "cycle_started_at": cycle_started_raw, + "cycle_started_at_normalized": cycle_started_norm, + }) + + return result[:limit] + + +def _normalize_cycle_started_at(cycle_started_at): + """Нормализира датата за цикъл до naive UTC без микросекунди – един и същ ключ при запис, търсене и показване.""" + if cycle_started_at is None: + return None + if hasattr(cycle_started_at, "year"): + dt = cycle_started_at + else: + dt = _parse_iso(cycle_started_at) + if dt is None: + return None + try: + if getattr(dt, "tzinfo", None): + dt = dt.astimezone(timezone.utc) + return dt.replace(microsecond=0, tzinfo=None) + except Exception: + try: + return dt.replace(microsecond=0, tzinfo=None) + except Exception: + return dt + + +def close_cycle_manually(conn, cycle_id): + """Служебно затваряне на цикъл: маркира този цикъл в ITD_Cycles (ServiceClosed=1).""" + try: + cycle_id = int(cycle_id) + except (TypeError, ValueError): + raise ValueError("Невалиден идентификатор на цикъл.") + cursor = conn.cursor() + cursor.execute(""" + UPDATE dbo.ITD_Cycles + SET ServiceClosed = 1, ServiceClosedAt = SYSUTCDATETIME() + WHERE Id = ? AND ServiceClosed = 0 + """, (cycle_id,)) + return cursor.rowcount > 0 + + +def undo_manual_cycle_close(conn, cycle_id): + """Отмяна на служебно затваряне: нулира ServiceClosed за този цикъл в ITD_Cycles.""" + try: + cycle_id = int(cycle_id) + except (TypeError, ValueError): + raise ValueError("Невалиден идентификатор на цикъл.") + cursor = conn.cursor() + cursor.execute(""" + UPDATE dbo.ITD_Cycles + SET ServiceClosed = 0, ServiceClosedAt = NULL + WHERE Id = ? + """, (cycle_id,)) + return cursor.rowcount > 0 + + +def get_card_by_code(conn, code): + """Търси карта по Code (QR стойност). Връща None или dict с Id, Code, DisplayText, IsActive.""" + if not code or not str(code).strip(): + return None + cursor = conn.cursor() + cursor.execute( + "SELECT Id, Code, DisplayText, IsActive FROM dbo.ITD_Cards WHERE Code = ? AND IsActive = 1", + (str(code).strip(),), + ) + row = cursor.fetchone() + if not row: + return None + return {"id": row.Id, "code": row.Code, "display_text": row.DisplayText, "is_active": bool(row.IsActive)} + + +def add_registration(conn, card_id, post_index): + """Добавя регистрация за карта на даден пост (1–5). Пост 1 = нов ред в ITD_Cycles; постове 2–5 = UPDATE на текущия цикъл.""" + if post_index == 1 and has_open_cycle(conn, card_id): + raise ValueError("Вече сте влезли. Моля, излезте преди нова регистрация.") + cursor = conn.cursor() + if post_index == 1: + cursor.execute(""" + INSERT INTO dbo.ITD_Cycles (CardId, CycleNo, Post1At, Post2At, Post3At, Post4At, Post5At, ServiceClosed) + VALUES (?, (SELECT ISNULL(MAX(CycleNo), 0) + 1 FROM dbo.ITD_Cycles WHERE CardId = ?), SYSUTCDATETIME(), NULL, NULL, NULL, NULL, 0) + """, (card_id, card_id)) + else: + cursor.execute(""" + SELECT TOP 1 Id FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post5At IS NULL AND ServiceClosed = 0 + ORDER BY Post1At DESC + """, (card_id,)) + row = cursor.fetchone() + if not row: + raise ValueError("Няма отворен цикъл за тази карта.") + col = f"Post{post_index}At" + cursor.execute(f"UPDATE dbo.ITD_Cycles SET {col} = SYSUTCDATETIME() WHERE Id = ?", (row.Id,)) + + +def set_card_active(conn, card_id, is_active): + """Активира или деактивира карта. При деактивирана карта не може да се влиза (регистрира).""" + cursor = conn.cursor() + cursor.execute( + "UPDATE dbo.ITD_Cards SET IsActive = ?, UpdatedAt = SYSUTCDATETIME() WHERE Id = ?", + (1 if is_active else 0, card_id), + ) + + +def get_last_post_index(conn, card_id): + """Връща последния попълнен пост (1–5) за дадена карта от последния цикъл, или None ако няма цикли.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 Post1At, Post2At, Post3At, Post4At, Post5At, ServiceClosed + FROM dbo.ITD_Cycles WHERE CardId = ? ORDER BY Post1At DESC + """, + (card_id,), + ) + row = cursor.fetchone() + if not row: + return None + if row.Post5At or getattr(row, "ServiceClosed", False): + return 5 + if row.Post4At: + return 4 + if row.Post3At: + return 3 + if row.Post2At: + return 2 + return 1 + + +def has_open_cycle(conn, card_id): + """True ако картата има отворен цикъл (ред в ITD_Cycles с Post5At IS NULL и ServiceClosed = 0).""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT 1 FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post5At IS NULL AND ServiceClosed = 0 + """, + (card_id,), + ) + return cursor.fetchone() is not None + + +def get_next_post_index(conn, card_id): + """Връща следващия пост за регистрация (1–5). След служебно затваряне или пост 5 следва пост 1.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 Post2At, Post3At, Post4At, Post5At, ServiceClosed + FROM dbo.ITD_Cycles WHERE CardId = ? ORDER BY Post1At DESC + """, + (card_id,), + ) + row = cursor.fetchone() + if not row: + return 1 + if getattr(row, "ServiceClosed", False) or row.Post5At: + return 1 + if not row.Post2At: + return 2 + if not row.Post3At: + return 3 + if not row.Post4At: + return 4 + return 5 + + +def load_cards(conn): + """Връща списък с всички карти за административния модул.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT Id, Code, DisplayText, IsActive, CreatedAt, UpdatedAt + FROM dbo.ITD_Cards + ORDER BY DisplayText + """ + ) + rows = cursor.fetchall() + result = [] + for r in rows: + result.append( + { + "id": r.Id, + "code": r.Code, + "display_text": r.DisplayText, + "is_active": bool(r.IsActive), + "created_at": r.CreatedAt, + "updated_at": r.UpdatedAt, + } + ) + return result + + +def delete_parking_only_cycles_for_card(conn, card_code): + """ + Изтрива всички цикли „само паркинг“ (само Post1At е попълнен) за карта с даден Code. + Връща (брой изтрити цикли, 0) за съвместимост със стария API. + """ + card_code = str(card_code).strip() + if not card_code: + raise ValueError("Кодът на картата е задължителен.") + cursor = conn.cursor() + cursor.execute("SELECT Id FROM dbo.ITD_Cards WHERE Code = ?", (card_code,)) + row = cursor.fetchone() + if not row: + return (0, 0) + card_id = int(row.Id) + cursor.execute( + """ + DELETE FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post2At IS NULL AND Post3At IS NULL AND Post4At IS NULL AND Post5At IS NULL + """, + (card_id,), + ) + return (cursor.rowcount, 0) + + +def create_card(conn, display_text, is_active=True): + """Създава нова карта. Кодът се задава служебно равен на Id. Връща Id на картата.""" + display_text = str(display_text).strip() + if not display_text: + raise ValueError("Текстът на картата е задължителен.") + cursor = conn.cursor() + # Временен уникален код (NEWID), след което Code се обновява на Id + cursor.execute( + """ + INSERT INTO dbo.ITD_Cards (Code, DisplayText, IsActive) + OUTPUT INSERTED.Id + VALUES (CONVERT(NVARCHAR(36), NEWID()), ?, ?) + """, + (display_text, 1 if is_active else 0), + ) + row = cursor.fetchone() + new_id = row[0] if row else None + if new_id is None: + raise RuntimeError("Картата е записана, но не може да се прочете новото Id.") + new_id = int(new_id) + cursor.execute("UPDATE dbo.ITD_Cards SET Code = ? WHERE Id = ?", (str(new_id), new_id)) + return new_id + + +def update_card(conn, card_id, display_text): + """Обновява текста на карта (DisplayText). Кодът не се променя.""" + display_text = str(display_text).strip() + if not display_text: + raise ValueError("Текстът на картата е задължителен.") + cursor = conn.cursor() + cursor.execute( + "UPDATE dbo.ITD_Cards SET DisplayText = ?, UpdatedAt = SYSUTCDATETIME() WHERE Id = ?", + (display_text, card_id), + ) + if cursor.rowcount == 0: + raise ValueError("Картата не е намерена.") + + +def delete_card(conn, card_id): + """Изтрива карта и всички нейни цикли (ITD_Cycles).""" + card_id = int(card_id) + cursor = conn.cursor() + cursor.execute("DELETE FROM dbo.ITD_Cycles WHERE CardId = ?", (card_id,)) + cursor.execute("DELETE FROM dbo.ITD_Cards WHERE Id = ?", (card_id,)) + if cursor.rowcount == 0: + raise ValueError("Картата не е намерена.") diff --git a/db_check.py b/db_check.py new file mode 100644 index 0000000..caef3a8 --- /dev/null +++ b/db_check.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ITD Transport Tracking - проверка за достъп до база данни. +Ако липсват таблиците ITD_Cards и ITD_Cycles, изпълнява schema.sql. +""" + +import os +import sys + +from itd_db import load_connection_string +try: + import pyodbc +except ImportError: + pyodbc = None + +def check_tables(cursor): + """Проверява дали съществуват таблиците ITD_Cards и ITD_Cycles.""" + cursor.execute(""" + SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME IN ('ITD_Cards', 'ITD_Cycles') + """) + found = {row[0] for row in cursor.fetchall()} + return "ITD_Cards" in found, "ITD_Cycles" in found + +def run_schema(cursor, schema_path): + """Изпълнява schema.sql (батчове разделени с GO).""" + with open(schema_path, "r", encoding="utf-8") as f: + content = f.read() + content = content.replace("\r\n", "\n") + # Разделяме по редове съдържащи само GO + batches = [] + current = [] + for line in content.split("\n"): + if line.strip().upper() == "GO": + batch = "\n".join(current).strip() + if batch and not all(l.strip().startswith("--") or not l.strip() for l in current): + batches.append(batch) + current = [] + else: + current.append(line) + if current: + batch = "\n".join(current).strip() + if batch: + batches.append(batch) + for batch in batches: + try: + cursor.execute(batch) + except pyodbc.Error as e: + print(f"Грешка при изпълнение на батч: {e}", file=sys.stderr) + raise + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + os.chdir(script_dir) + + print("ITD: проверка за достъп до база данни...") + try: + conn_str = load_connection_string() + except Exception as e: + print(f"Грешка при зареждане на настройките: {e}", file=sys.stderr) + return 1 + + if pyodbc is None: + print("Грешка: инсталирайте pyodbc (pip install pyodbc)", file=sys.stderr) + return 1 + + try: + conn = pyodbc.connect(conn_str, autocommit=True) + except Exception as e: + print(f"Грешка при свързване към SQL Server: {e}", file=sys.stderr) + return 1 + + try: + cursor = conn.cursor() + schema_path = os.path.join(script_dir, "schema.sql") + if os.path.isfile(schema_path): + run_schema(cursor, schema_path) + has_cards, has_cycles = check_tables(cursor) + if has_cards and has_cycles: + print("Достъп до база данни: OK. Таблиците ITD_Cards и ITD_Cycles съществуват.") + return 0 + print("Внимание: след изпълнение на schema.sql липсват таблици.", file=sys.stderr) + return 1 + finally: + conn.close() + +if __name__ == "__main__": + sys.exit(main()) diff --git a/delete_123123_parking.py b/delete_123123_parking.py new file mode 100644 index 0000000..3a4a21d --- /dev/null +++ b/delete_123123_parking.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""Еднократно изтриване на записи с код 123123, които имат само паркинг (само пост 1).""" + +import sys +import os + +# работи от директорията на проекта +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import itd_db as db + +def main(): + conn = db.get_connection() + try: + deleted_regs, deleted_closes = db.delete_parking_only_cycles_for_card(conn, "123123") + print(f"Готово. Изтрити регистрации (само паркинг): {deleted_regs}") + print(f"Изтрити служебни затваряния за тези цикли: {deleted_closes}") + except Exception as e: + print(f"Грешка: {e}") + return 1 + finally: + try: + conn.close() + except Exception: + pass + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/itd_db.py b/itd_db.py new file mode 100644 index 0000000..48a9b7b --- /dev/null +++ b/itd_db.py @@ -0,0 +1,527 @@ +# -*- coding: utf-8 -*- +"""ITD - общ модул за връзка и зареждане на данни от базата.""" + +import json +import os +from datetime import datetime, timezone + +try: + import pyodbc +except ImportError: + pyodbc = None + + +_LAST_APPSETTINGS_PATH = None +_LAST_CONNECTION_STRING_REDACTED = None + + +def get_last_appsettings_path(): + """Връща пълния път към последно намерения appsettings.json (или None).""" + return _LAST_APPSETTINGS_PATH + + +def get_last_connection_string_redacted(): + """Връща последно използвания connection string с маскирани пароли (или None).""" + return _LAST_CONNECTION_STRING_REDACTED + + +def _redact_conn_str(s: str) -> str: + if not s: + return s + redacted_parts = [] + for part in str(s).split(";"): + p = part.strip() + if not p: + continue + if "=" not in p: + redacted_parts.append(p) + continue + k, v = p.split("=", 1) + kl = k.strip().lower() + if kl in ("pwd", "password"): + redacted_parts.append(f"{k}=***") + else: + redacted_parts.append(f"{k}={v}") + return ";".join(redacted_parts) + ";" + + +def load_connection_string(): + """Зарежда connection string от appsettings.json.""" + import sys + + global _LAST_APPSETTINGS_PATH + global _LAST_CONNECTION_STRING_REDACTED + + paths = [] + if getattr(sys, "frozen", False): + paths.append(os.path.join(os.path.dirname(sys.executable), "appsettings.json")) + paths.extend(("appsettings.json", os.path.join(os.path.dirname(__file__), "appsettings.json"))) + for path in paths: + if os.path.isfile(path): + _LAST_APPSETTINGS_PATH = os.path.abspath(path) + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + cs = cfg.get("ConnectionStrings", {}) + sql = cs.get("SqlServer", "") + if sql: + parts = {} + for part in sql.split(";"): + part = part.strip() + if not part or "=" not in part: + continue + k, v = part.split("=", 1) + parts[k.strip().lower()] = v.strip() + driver = "ODBC Driver 17 for SQL Server" + conn_str = ( + f"DRIVER={{{driver}}};" + f"SERVER={parts.get('server', '')};" + f"DATABASE={parts.get('database', '')};" + f"UID={parts.get('user id', '')};" + f"PWD={parts.get('password', '')};" + + ("TrustServerCertificate=yes;" if parts.get("trustservercertificate", "").lower() == "true" else "") + ) + _LAST_CONNECTION_STRING_REDACTED = _redact_conn_str(conn_str) + return conn_str + + if cs.get("Odbc"): + conn_str = cs["Odbc"] + _LAST_CONNECTION_STRING_REDACTED = _redact_conn_str(conn_str) + return conn_str + + raise ValueError("В appsettings.json липсва ConnectionStrings:SqlServer или Odbc") + tried = [os.path.abspath(p) for p in paths] + raise FileNotFoundError("Не е намерен appsettings.json. Пробвани пътища:\n- " + "\n- ".join(tried)) + + +def get_connection(): + """Отваря връзка към SQL Server (autocommit).""" + if pyodbc is None: + raise RuntimeError("Инсталирайте pyodbc: pip install pyodbc") + conn = pyodbc.connect(load_connection_string()) + conn.autocommit = True + return conn + + +def _parse_iso(s): + if s is None: + return None + if hasattr(s, "isoformat"): + return s + try: + return datetime.fromisoformat(str(s).replace("Z", "+00:00")) + except Exception: + return None + + +def _utc_to_local(dt): + """ + Преобразува datetime от БД (съхранен като UTC чрез SYSUTCDATETIME()) в локално време за показване. + Така няма разминаване с 2 часа (UTC+2) при потребители в България. + """ + if dt is None: + return None + if hasattr(dt, "tzinfo") and dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + # naive datetime от pyodbc = считаме за UTC + return dt.replace(tzinfo=timezone.utc).astimezone().replace(tzinfo=None) + + +def utc_to_local_for_display(dt): + """Публична функция за показване на дата от БД в локално време (за main.py и др.).""" + return _utc_to_local(dt) + + +def _format_duration(minutes): + if minutes is None: + return "" + if minutes < 60: + return f"{int(minutes)}м" + h, m = divmod(int(minutes), 60) + return f"{h}ч {m}м" + + +def load_cycles(conn, limit=200): + """ + Зарежда последните цикли от ITD_Cycles. + Всеки ред = един цикъл с 5 полета за време (Post1At..Post5At) + ServiceClosed. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT y.Id, y.CardId, y.CycleNo, y.Post1At, y.Post2At, y.Post3At, y.Post4At, y.Post5At, + y.ServiceClosed, y.ServiceClosedAt, + c.Code, c.DisplayText, c.IsActive, c.UpdatedAt + FROM dbo.ITD_Cycles y + JOIN dbo.ITD_Cards c ON c.Id = y.CardId + ORDER BY y.Post1At DESC + """ + ) + rows = cursor.fetchall() + + result = [] + for r in rows: + cycle_id = r.Id + card_id = r.CardId + t1 = _utc_to_local(_parse_iso(r.Post1At)) + t2 = _utc_to_local(_parse_iso(r.Post2At)) + t3 = _utc_to_local(_parse_iso(r.Post3At)) + t4 = _utc_to_local(_parse_iso(r.Post4At)) + t5 = _utc_to_local(_parse_iso(r.Post5At)) + service_closed = bool(getattr(r, "ServiceClosed", False)) + service_closed_at = getattr(r, "ServiceClosedAt", None) + manual_dt = _utc_to_local(_parse_iso(service_closed_at)) if service_closed_at else None + + def _fmt_dt(dt): + return dt.strftime("%d.%m %H:%M") if dt else "" + + post_times = { + 1: _fmt_dt(t1), + 2: _fmt_dt(t2), + 3: _fmt_dt(t3), + 4: _fmt_dt(t4), + 5: _fmt_dt(t5), + } + + post_durations = {1: "", 2: "", 3: "", 4: "", 5: ""} + if t1 and t2: + post_durations[1] = _format_duration((t2 - t1).total_seconds() / 60) + if t2 and t3: + post_durations[2] = _format_duration((t3 - t2).total_seconds() / 60) + if t3 and t4: + post_durations[3] = _format_duration((t4 - t3).total_seconds() / 60) + if t4 and t5: + post_durations[4] = _format_duration((t5 - t4).total_seconds() / 60) + + reg_dt = t2 or t1 + reg_str = reg_dt.strftime("%d.%m.%Y %H:%M") if reg_dt else "" + + if t1 and t2: + time_reg_entry = _format_duration((t2 - t1).total_seconds() / 60) + else: + time_reg_entry = "" + + parts = [] + if t2 and t3: + parts.append(f"2→3: {_format_duration((t3 - t2).total_seconds() / 60)}") + if t3 and t4: + parts.append(f"3→4: {_format_duration((t4 - t3).total_seconds() / 60)}") + if t4 and t5: + parts.append(f"4→5: {_format_duration((t5 - t4).total_seconds() / 60)}") + time_between = " | ".join(parts) if parts else "" + + exit_dt = t5 or manual_dt + service_exit = bool(service_closed and not t5) + if not exit_dt: + exit_str = "" + elif service_closed and not t5: + # Служебно затваряне – запазваме сегашния текст (дата/час) + exit_str = manual_dt.strftime("%d.%m.%Y %H:%M") if manual_dt else "Служебен изход" + if manual_dt: + exit_str = f"Служебен изход - {exit_str}" + else: + # Нормално напускане (има пост 5) – показваме общ престой от пост 1 до пост 5 + if t1 and t5: + total_mins = (t5 - t1).total_seconds() / 60 + exit_str = _format_duration(total_mins) + else: + exit_str = exit_dt.strftime("%d.%m.%Y %H:%M") + + can_close = t5 is None and not service_closed + cycle_started_raw = r.Post1At + cycle_started_norm = _normalize_cycle_started_at(cycle_started_raw) + + result.append( + { + "cycle_id": cycle_id, + "code": getattr(r, "Code", None) or "", + "display_text": r.DisplayText, + "reg_datetime": reg_str, + "time_reg_entry": time_reg_entry, + "time_between_posts": time_between, + "exit_datetime": exit_str, + "post1": f"{post_times[1]} {post_durations[1]}", + "post2": f"{post_times[2]} {post_durations[2]}", + "post3": f"{post_times[3]} {post_durations[3]}", + "post4": f"{post_times[4]} {post_durations[4]}", + "post5": f"{post_times[5]} {post_durations[5]}", + "service_exit": service_exit, + "can_close": can_close, + "card_id": card_id, + "is_active": bool(getattr(r, "IsActive", 1)), + "card_updated_at": getattr(r, "UpdatedAt", None), + "cycle_started_at": cycle_started_raw, + "cycle_started_at_normalized": cycle_started_norm, + } + ) + + return result[:limit] + + +def _normalize_cycle_started_at(cycle_started_at): + """Нормализира датата за цикъл до naive UTC без микросекунди – един и същ ключ при запис, търсене и показване.""" + if cycle_started_at is None: + return None + if hasattr(cycle_started_at, "year"): + dt = cycle_started_at + else: + dt = _parse_iso(cycle_started_at) + if dt is None: + return None + try: + if getattr(dt, "tzinfo", None): + dt = dt.astimezone(timezone.utc) + return dt.replace(microsecond=0, tzinfo=None) + except Exception: + try: + return dt.replace(microsecond=0, tzinfo=None) + except Exception: + return dt + + +def close_cycle_manually(conn, cycle_id): + """Служебно затваряне на цикъл: маркира този цикъл в ITD_Cycles (ServiceClosed=1).""" + try: + cycle_id = int(cycle_id) + except (TypeError, ValueError): + raise ValueError("Невалиден идентификатор на цикъл.") + cursor = conn.cursor() + cursor.execute( + """ + UPDATE dbo.ITD_Cycles + SET ServiceClosed = 1, ServiceClosedAt = SYSUTCDATETIME() + WHERE Id = ? AND ServiceClosed = 0 + """, + (cycle_id,), + ) + return cursor.rowcount > 0 + + +def undo_manual_cycle_close(conn, cycle_id): + """Отмяна на служебно затваряне: нулира ServiceClosed за този цикъл в ITD_Cycles.""" + try: + cycle_id = int(cycle_id) + except (TypeError, ValueError): + raise ValueError("Невалиден идентификатор на цикъл.") + cursor = conn.cursor() + cursor.execute( + """ + UPDATE dbo.ITD_Cycles + SET ServiceClosed = 0, ServiceClosedAt = NULL + WHERE Id = ? + """, + (cycle_id,), + ) + return cursor.rowcount > 0 + + +def get_card_by_code(conn, code): + """Търси карта по Code (QR стойност). Връща None или dict с Id, Code, DisplayText, IsActive.""" + if not code or not str(code).strip(): + return None + cursor = conn.cursor() + cursor.execute( + "SELECT Id, Code, DisplayText, IsActive FROM dbo.ITD_Cards WHERE Code = ? AND IsActive = 1", + (str(code).strip(),), + ) + row = cursor.fetchone() + if not row: + return None + return {"id": row.Id, "code": row.Code, "display_text": row.DisplayText, "is_active": bool(row.IsActive)} + + +def add_registration(conn, card_id, post_index): + """Добавя регистрация за карта на даден пост (1–5). Пост 1 = нов ред в ITD_Cycles; постове 2–5 = UPDATE на текущия цикъл.""" + if post_index == 1 and has_open_cycle(conn, card_id): + raise ValueError("Вече сте влезли. Моля, излезте преди нова регистрация.") + cursor = conn.cursor() + if post_index == 1: + cursor.execute( + """ + INSERT INTO dbo.ITD_Cycles (CardId, CycleNo, Post1At, Post2At, Post3At, Post4At, Post5At, ServiceClosed) + VALUES (?, (SELECT ISNULL(MAX(CycleNo), 0) + 1 FROM dbo.ITD_Cycles WHERE CardId = ?), SYSUTCDATETIME(), NULL, NULL, NULL, NULL, 0) + """, + (card_id, card_id), + ) + else: + cursor.execute( + """ + SELECT TOP 1 Id FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post5At IS NULL AND ServiceClosed = 0 + ORDER BY Post1At DESC + """, + (card_id,), + ) + row = cursor.fetchone() + if not row: + raise ValueError("Няма отворен цикъл за тази карта.") + col = f"Post{post_index}At" + cursor.execute(f"UPDATE dbo.ITD_Cycles SET {col} = SYSUTCDATETIME() WHERE Id = ?", (row.Id,)) + + +def set_card_active(conn, card_id, is_active): + """Активира или деактивира карта. При деактивирана карта не може да се влиза (регистрира).""" + cursor = conn.cursor() + cursor.execute( + "UPDATE dbo.ITD_Cards SET IsActive = ?, UpdatedAt = SYSUTCDATETIME() WHERE Id = ?", + (1 if is_active else 0, card_id), + ) + + +def get_last_post_index(conn, card_id): + """Връща последния попълнен пост (1–5) за дадена карта от последния цикъл, или None ако няма цикли.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 Post1At, Post2At, Post3At, Post4At, Post5At, ServiceClosed + FROM dbo.ITD_Cycles WHERE CardId = ? ORDER BY Post1At DESC + """, + (card_id,), + ) + row = cursor.fetchone() + if not row: + return None + if row.Post5At or getattr(row, "ServiceClosed", False): + return 5 + if row.Post4At: + return 4 + if row.Post3At: + return 3 + if row.Post2At: + return 2 + return 1 + + +def has_open_cycle(conn, card_id): + """True ако картата има отворен цикъл (ред в ITD_Cycles с Post5At IS NULL и ServiceClosed = 0).""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT 1 FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post5At IS NULL AND ServiceClosed = 0 + """, + (card_id,), + ) + return cursor.fetchone() is not None + + +def get_next_post_index(conn, card_id): + """Връща следващия пост за регистрация (1–5). След служебно затваряне или пост 5 следва пост 1.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 Post2At, Post3At, Post4At, Post5At, ServiceClosed + FROM dbo.ITD_Cycles WHERE CardId = ? ORDER BY Post1At DESC + """, + (card_id,), + ) + row = cursor.fetchone() + if not row: + return 1 + if getattr(row, "ServiceClosed", False) or row.Post5At: + return 1 + if not row.Post2At: + return 2 + if not row.Post3At: + return 3 + if not row.Post4At: + return 4 + return 5 + + +def load_cards(conn): + """Връща списък с всички карти за административния модул.""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT Id, Code, DisplayText, IsActive, CreatedAt, UpdatedAt + FROM dbo.ITD_Cards + ORDER BY DisplayText + """ + ) + rows = cursor.fetchall() + result = [] + for r in rows: + result.append( + { + "id": r.Id, + "code": r.Code, + "display_text": r.DisplayText, + "is_active": bool(r.IsActive), + "created_at": r.CreatedAt, + "updated_at": r.UpdatedAt, + } + ) + return result + + +def delete_parking_only_cycles_for_card(conn, card_code): + """ + Изтрива всички цикли „само паркинг“ (само Post1At е попълнен) за карта с даден Code. + Връща (брой изтрити цикли, 0) за съвместимост със стария API. + """ + card_code = str(card_code).strip() + if not card_code: + raise ValueError("Кодът на картата е задължителен.") + cursor = conn.cursor() + cursor.execute("SELECT Id FROM dbo.ITD_Cards WHERE Code = ?", (card_code,)) + row = cursor.fetchone() + if not row: + return (0, 0) + card_id = int(row.Id) + cursor.execute( + """ + DELETE FROM dbo.ITD_Cycles + WHERE CardId = ? AND Post2At IS NULL AND Post3At IS NULL AND Post4At IS NULL AND Post5At IS NULL + """, + (card_id,), + ) + return (cursor.rowcount, 0) + + +def create_card(conn, display_text, is_active=True): + """Създава нова карта. Кодът се задава служебно равен на Id. Връща Id на картата.""" + display_text = str(display_text).strip() + if not display_text: + raise ValueError("Текстът на картата е задължителен.") + cursor = conn.cursor() + # Временен уникален код (NEWID), след което Code се обновява на Id + cursor.execute( + """ + INSERT INTO dbo.ITD_Cards (Code, DisplayText, IsActive) + OUTPUT INSERTED.Id + VALUES (CONVERT(NVARCHAR(36), NEWID()), ?, ?) + """, + (display_text, 1 if is_active else 0), + ) + row = cursor.fetchone() + new_id = row[0] if row else None + if new_id is None: + raise RuntimeError("Картата е записана, но не може да се прочете новото Id.") + new_id = int(new_id) + cursor.execute("UPDATE dbo.ITD_Cards SET Code = ? WHERE Id = ?", (str(new_id), new_id)) + return new_id + + +def update_card(conn, card_id, display_text): + """Обновява текста на карта (DisplayText). Кодът не се променя.""" + display_text = str(display_text).strip() + if not display_text: + raise ValueError("Текстът на картата е задължителен.") + cursor = conn.cursor() + cursor.execute( + "UPDATE dbo.ITD_Cards SET DisplayText = ?, UpdatedAt = SYSUTCDATETIME() WHERE Id = ?", + (display_text, card_id), + ) + if cursor.rowcount == 0: + raise ValueError("Картата не е намерена.") + + +def delete_card(conn, card_id): + """Изтрива карта и всички нейни цикли (ITD_Cycles).""" + card_id = int(card_id) + cursor = conn.cursor() + cursor.execute("DELETE FROM dbo.ITD_Cycles WHERE CardId = ?", (card_id,)) + cursor.execute("DELETE FROM dbo.ITD_Cards WHERE Id = ?", (card_id,)) + if cursor.rowcount == 0: + raise ValueError("Картата не е намерена.") + diff --git a/itd_transport.log b/itd_transport.log new file mode 100644 index 0000000..8c69f1c --- /dev/null +++ b/itd_transport.log @@ -0,0 +1 @@ +[STARTUP] OK. appsettings=C:\~ip\app-dblib\cursor_projects\ITD-desktop\appsettings.json diff --git a/main.py b/main.py new file mode 100644 index 0000000..0a38aeb --- /dev/null +++ b/main.py @@ -0,0 +1,893 @@ +# -*- coding: utf-8 -*- +""" +ITD Transport Tracking - основен екран. +Поле за четене на QR карта, регистрация по пост; генериране на QR карта; таблица с цикли. +""" + +import os +import sys +import platform +import subprocess +import tkinter as tk +from tkinter import ttk, filedialog + +# работима директория = тази на скрипта (при .exe = папката на exe) +# Забележка: НЕ пипаме sys.path преди локалните импорти; PyInstaller има собствен importer. +if getattr(sys, "frozen", False): + SCRIPT_DIR = os.path.dirname(sys.executable) +else: + SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + +from itd_db import ( + get_connection, + get_last_appsettings_path, + get_last_connection_string_redacted, + load_cycles, + load_cards, + close_cycle_manually, + undo_manual_cycle_close, + get_card_by_code, + add_registration, + create_card, + update_card, + delete_card, + get_next_post_index, + has_open_cycle, + set_card_active, + utc_to_local_for_display, +) + +os.chdir(SCRIPT_DIR) + +POST_NAMES = { + 1: "1 – Портал (паркинг)", + 2: "2 – Вход в завода", + 3: "3 – Рампа начало товарене", + 4: "4 – Рампа край товарене", + 5: "5 – Напускане", +} + + +def _append_log(line: str): + try: + log_path = os.path.join(SCRIPT_DIR, "itd_transport.log") + with open(log_path, "a", encoding="utf-8") as f: + f.write(line.rstrip() + "\n") + except Exception: + pass + + +def _center_on_parent(dialog, parent): + """Същата логика като при „Генерирай QR карта“ – центриране в средата на parent.""" + dialog.update_idletasks() + parent.update_idletasks() + w = dialog.winfo_width() + h = dialog.winfo_height() + if w < 50 or h < 50: + w = dialog.winfo_reqwidth() + h = dialog.winfo_reqheight() + pw = parent.winfo_width() + ph = parent.winfo_height() + px = parent.winfo_rootx() + py = parent.winfo_rooty() + if pw < 50 or ph < 50: + sw = dialog.winfo_screenwidth() + sh = dialog.winfo_screenheight() + x = max(0, (sw - w) // 2) + y = max(0, (sh - h) // 2) + else: + x = px + (pw - w) // 2 + y = py + (ph - h) // 2 + dialog.geometry(f"+{x}+{y}") + + +def _msg_centered(parent, title, message, icon_type="info"): + """Показва модален диалог с една бутон OK – центриране като при „Генерирай QR карта“.""" + win = tk.Toplevel(parent) + win.withdraw() + win.transient(parent) + win.title(title) + win.resizable(False, False) + f = ttk.Frame(win, padding=20) + f.pack(fill=tk.BOTH, expand=True) + ttk.Label(f, text=message, wraplength=420).pack(pady=(0, 16)) + ttk.Button(f, text="OK", command=win.destroy).pack() + _center_on_parent(win, parent) + win.deiconify() + win.grab_set() + win.focus_set() + win.wait_window() + + +def _ask_yesno_centered(parent, title, message): + """Показва модален Да/Не диалог – центриране като при „Генерирай QR карта“. Връща True за Да, False за Не.""" + result = [False] + + def on_yes(): + result[0] = True + win.destroy() + + def on_no(): + result[0] = False + win.destroy() + + win = tk.Toplevel(parent) + win.withdraw() + win.transient(parent) + win.title(title) + win.resizable(False, False) + f = ttk.Frame(win, padding=20) + f.pack(fill=tk.BOTH, expand=True) + ttk.Label(f, text=message, wraplength=420).pack(pady=(0, 16)) + bf = ttk.Frame(f) + bf.pack() + ttk.Button(bf, text="Да", command=on_yes).pack(side=tk.LEFT, padx=6) + ttk.Button(bf, text="Не", command=on_no).pack(side=tk.LEFT, padx=6) + _center_on_parent(win, parent) + win.deiconify() + win.grab_set() + win.focus_set() + win.wait_window() + return result[0] + + +def main(): + root = tk.Tk() + root.withdraw() + try: + conn = get_connection() + except Exception as e: + cfg_path = get_last_appsettings_path() + cs = get_last_connection_string_redacted() + _append_log(f"[STARTUP] DB connect failed. appsettings={cfg_path or 'N/A'}; conn={cs or 'N/A'}; error={e!s}") + root.deiconify() + root.update_idletasks() + extra = f"\n\nИзползван appsettings.json:\n{cfg_path}" if cfg_path else "" + _msg_centered(root, "Грешка", f"Не може да се свърже с базата:\n{e}{extra}") + try: + root.destroy() + except Exception: + pass + return 1 + + cfg_path = get_last_appsettings_path() + cs = get_last_connection_string_redacted() + _append_log(f"[STARTUP] OK. appsettings={cfg_path or 'N/A'}; conn={cs or 'N/A'}") + + # Автоматично създаване на таблици при липса (трансфер на нов сървер) + try: + from db_check import run_schema, check_tables + _base = getattr(sys, "_MEIPASS", SCRIPT_DIR) + schema_path = os.path.join(_base, "schema.sql") + if os.path.isfile(schema_path): + run_schema(conn.cursor(), schema_path) + has_cards, has_cycles = check_tables(conn.cursor()) + if not (has_cards and has_cycles): + root.deiconify() + root.update_idletasks() + _msg_centered(root, "Грешка", "След изпълнение на схемата липсват таблици ITD_Cards или ITD_Cycles.") + return 1 + except Exception as e: + root.deiconify() + root.update_idletasks() + _msg_centered(root, "Грешка", f"Грешка при проверка/създаване на таблици:\n{e}") + return 1 + + root.deiconify() + root.title("ITD Transport Tracking") + root.minsize(800, 400) + root.geometry("1000x560") + root.configure(bg="white") + + # Икона на приложението – използваме само ITD.ico в папката на скрипта + icon_path = os.path.join(SCRIPT_DIR, "ITD.ico") + if os.path.isfile(icon_path): + try: + root.iconbitmap(icon_path) + except Exception: + pass + # PNG икона (за платформи, където .ico не се поддържа) + for icon_name in ("icon.png", "app.png"): + icon_path = os.path.join(SCRIPT_DIR, icon_name) + if os.path.isfile(icon_path): + try: + from PIL import Image + img = Image.open(icon_path) + from PIL import ImageTk + root.iconphoto(True, ImageTk.PhotoImage(img)) + except Exception: + pass + break + + # --- Заглавна лента: по-едър шрифт, светлосин фон, текст центриран --- + title_frame = tk.Frame(root, bg="#93C5FD", height=52) + title_frame.grid(row=0, column=0, columnspan=2, sticky="ew") + title_frame.grid_propagate(False) + root.grid_columnconfigure(0, weight=1) + title_label = tk.Label( + title_frame, + text="ITD Transport Tracking", + font=("Segoe UI", 16, "bold"), + fg="#1E3A8A", + bg="#93C5FD", + ) + title_label.place(relx=0.5, rely=0.5, anchor="center") + + # --- Горна лента: четене на QR карта и пост --- + scan_frame = ttk.LabelFrame(root, text="", padding=6) + scan_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=4, pady=4) + root.grid_columnconfigure(0, weight=1) + + ttk.Label(scan_frame, text="Постове / Терминали:").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") + + posts_frame = ttk.Frame(scan_frame) + posts_frame.grid(row=0, column=1, padx=0, pady=2, sticky="w") + + post_colors = { + 1: "#BFDBFE", # по-наситено синьо + 2: "#BBF7D0", # по-наситено зелено + 3: "#FDE68A", # по-наситено златисто + 4: "#DDD6FE", # по-наситено лилаво + 5: "#FCA5A5", # по-наситено червено + } + + for i in range(1, 6): + lbl = tk.Label( + posts_frame, + text=POST_NAMES[i], + padx=10, + pady=6, + font=("Segoe UI", 9), + fg="#111827", + bg=post_colors.get(i, "#4F4F4F"), + bd=0, + relief="flat", + highlightthickness=1, + highlightbackground="#D1D5DB", + ) + lbl.pack(side=tk.LEFT, padx=(0, 6)) + + ttk.Label(scan_frame, text="QR карта (код):").grid(row=1, column=0, padx=(0, 4), pady=(6, 4), sticky="w") + qr_row_frame = ttk.Frame(scan_frame) + qr_row_frame.grid(row=1, column=1, padx=0, pady=(6, 4), sticky="ew") + scan_frame.columnconfigure(1, weight=1) + qr_entry = ttk.Entry(qr_row_frame, width=40, font=("Segoe UI", 10)) + qr_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8), ipady=2) + + def on_register(): + code = qr_entry.get().strip() + if not code: + root.update_idletasks() + _msg_centered(root, "Информация", "Въведете или сканирайте кода на картата.") + qr_entry.focus_set() + return + card = get_card_by_code(conn, code) + if not card: + root.update_idletasks() + _msg_centered(root, "Внимание", f"Карта с код „{code}” не е намерена или е неактивна.") + qr_entry.select_range(0, tk.END) + qr_entry.focus_set() + return + try: + post_idx = get_next_post_index(conn, card["id"]) + if post_idx == 1 and has_open_cycle(conn, card["id"]): + root.update_idletasks() + _msg_centered( + root, + "Внимание", + "Вече сте влезли. Моля, излезте преди нова регистрация.", + ) + qr_entry.select_range(0, tk.END) + qr_entry.focus_set() + return + add_registration(conn, card["id"], post_idx) + qr_entry.delete(0, tk.END) + qr_entry.focus_set() + refresh() + root.update_idletasks() + _msg_centered(root, "Готово", f"Регистрирано: {card['display_text']} на пост {post_idx}.") + except Exception as e: + root.update_idletasks() + _msg_centered(root, "Грешка", f"Неуспешна регистрация:\n{e}") + + ttk.Button(qr_row_frame, text="Регистрирай", command=on_register).pack(side=tk.LEFT) + qr_entry.bind("", lambda e: on_register()) + + def open_qr_dialog(): + win = tk.Toplevel(root) + win.title("Генериране на QR карта") + win.transient(root) + win.grab_set() + f = ttk.Frame(win, padding=12) + f.pack(fill=tk.BOTH, expand=True) + ttk.Label(f, text="Код (ID):").grid(row=0, column=0, sticky="w", pady=2) + code_label = ttk.Label(f, text="—", foreground="gray") + code_label.grid(row=0, column=1, padx=8, pady=2, sticky="w") + ttk.Label(f, text="(задава се автоматично след запис в базата)").grid( + row=0, column=2, sticky="w", pady=2 + ) + ttk.Label(f, text="Текст на картата (име + № кола):").grid(row=1, column=0, sticky="w", pady=2) + text_ent = ttk.Entry(f, width=35) + text_ent.grid(row=1, column=1, padx=8, pady=2, columnspan=2, sticky="ew") + f.columnconfigure(1, weight=1) + save_to_db_var = tk.BooleanVar(value=True) + ttk.Checkbutton(f, text="Запиши карта в базата", variable=save_to_db_var).grid( + row=2, column=0, columnspan=2, sticky="w", pady=4 + ) + qr_label = ttk.Label(f, text="") + qr_label.grid(row=3, column=0, columnspan=2, pady=8) + + def do_generate(): + display_text = text_ent.get().strip() + if not display_text: + win.update_idletasks() + _msg_centered(win, "Внимание", "Въведете текст на картата.") + text_ent.focus_set() + return + if not save_to_db_var.get(): + win.update_idletasks() + _msg_centered( + win, + "Внимание", + "За да генерирате QR карта с код, отметнете „Запиши карта в базата“.", + ) + return + try: + new_id = create_card(conn, display_text) + code = str(new_id) + code_label.config(text=code, foreground="#111827") + import qrcode + from PIL import ImageTk + qr = qrcode.QRCode(version=1, box_size=6, border=2) + qr.add_data(code) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img = img.convert("RGB").resize((220, 220)) + ph = ImageTk.PhotoImage(img) + qr_label.configure(image=ph, text="") + qr_label.image = ph + win.update_idletasks() + _msg_centered(win, "Готово", f"Картата е записана с код {code}. Отпечатайте QR кода по-долу.") + except Exception as ex: + win.update_idletasks() + _msg_centered(win, "Грешка", str(ex)) + + ttk.Button(f, text="Генерирай QR", command=do_generate).grid(row=4, column=0, columnspan=2, pady=6) + # центриране на прозореца в средата на главния прозорец + win.update_idletasks() + root.update_idletasks() + w = win.winfo_width() + h = win.winfo_height() + rw = root.winfo_width() + rh = root.winfo_height() + rx = root.winfo_rootx() + ry = root.winfo_rooty() + x = rx + (rw - w) // 2 + y = ry + (rh - h) // 2 + win.geometry(f"+{x}+{y}") + text_ent.focus_set() + + # Филтър по номер на карта + filter_frame = ttk.Frame(root) + filter_frame.grid(row=2, column=0, columnspan=2, sticky="ew", padx=4, pady=(0, 4)) + root.grid_columnconfigure(0, weight=1) + ttk.Label(filter_frame, text="Филтър по карта (номер):").pack(side=tk.LEFT, padx=(0, 6)) + filter_entry = ttk.Entry(filter_frame, width=14, font=("Segoe UI", 10)) + filter_entry.pack(side=tk.LEFT, padx=(0, 6)) + + card_filter = [None] # mutable, за достъп от вложени функции + + def apply_filter(): + card_filter[0] = (filter_entry.get() or "").strip() or None + refresh() + + def clear_filter(): + filter_entry.delete(0, tk.END) + card_filter[0] = None + refresh() + + ttk.Button(filter_frame, text="Филтрирай", command=apply_filter).pack(side=tk.LEFT, padx=2) + ttk.Button(filter_frame, text="Всички", command=clear_filter).pack(side=tk.LEFT, padx=2) + + # платно за визуализация на цикли с цветни постове + row_height = 40 + canvas = tk.Canvas(root, background="white") + scroll_y = ttk.Scrollbar(root, orient=tk.VERTICAL, command=canvas.yview) + canvas.configure(yscrollcommand=scroll_y.set) + + # данни за редовете (card_id, cycle_started_at и др.) + cycles_data = [] + selected_row_index = None + + post_colors = { + 1: "#BFDBFE", # по-наситено синьо + 2: "#BBF7D0", # по-наситено зелено + 3: "#FDE68A", # по-наситено златисто + 4: "#DDD6FE", # по-наситено лилаво + 5: "#FCA5A5", # по-наситено червено + } + + def draw_rows(): + canvas.delete("all") + width = canvas.winfo_width() or 1000 + card_width = 200 + post_width = 108 + gap = 4 + # Служебен изход / изход веднага след пост 5 + after_posts_x = card_width + 5 * (post_width + gap) + exit_x = after_posts_x + 8 + + for idx, r in enumerate(cycles_data): + y0 = idx * row_height + y1 = y0 + row_height + + # фон на реда (за селекция) + bg_color = "#E5E7EB" if idx == selected_row_index else "#FFFFFF" + canvas.create_rectangle(0, y0, width, y1, fill=bg_color, outline="#E5E7EB") + + # текст на картата: "код" - "име" + card_label = f'{r.get("code", "")} - {r["display_text"]}' if r.get("code") else r["display_text"] + canvas.create_text( + 8, + (y0 + y1) / 2, + text=card_label, + anchor="w", + font=("Segoe UI", 9), + fill="#111827", + ) + + # 5 цветни правоъгълника за постовете + for p in range(1, 6): + x0 = card_width + (p - 1) * (post_width + gap) + x1 = x0 + post_width + txt = r.get(f"post{p}", "") + canvas.create_rectangle( + x0, + y0 + 4, + x1, + y1 - 4, + fill=post_colors.get(p, "#9CA3AF"), + outline="#E5E7EB", + ) + canvas.create_text( + (x0 + x1) / 2, + (y0 + y1) / 2, + text=txt, + anchor="center", + font=("Segoe UI", 8), + fill="#111827", + ) + + # изход / служебен изход (веднага след пост 5) + exit_text = r.get("exit_datetime", "") + exit_fill = "#B91C1C" if r.get("service_exit") else "#111827" + canvas.create_text( + exit_x, + (y0 + y1) / 2, + text=exit_text, + anchor="w", + font=("Segoe UI", 9), + fill=exit_fill, + ) + + canvas.configure(scrollregion=canvas.bbox("all")) + + def refresh(event=None): + nonlocal cycles_data, selected_row_index + cycles_data.clear() + selected_row_index = None + try: + rows = load_cycles(conn) + except Exception as e: + root.update_idletasks() + _msg_centered(root, "Грешка", f"Грешка при зареждане:\n{e}") + return + cf = card_filter[0] + if cf: + cf_str = str(cf).strip() + rows = [r for r in rows if str(r.get("code", "")).strip() == cf_str] + for r in rows: + cycles_data.append(r) + status_label.config(text=f"Заредени {len(cycles_data)} цикъла" + (f" (филтър: {cf})" if cf else "")) + draw_rows() + + def on_canvas_click(event): + nonlocal selected_row_index + y = canvas.canvasy(event.y) + idx = int(y // row_height) + if 0 <= idx < len(cycles_data): + selected_row_index = idx + draw_rows() + + canvas.bind("", on_canvas_click) + canvas.bind("", refresh) + + def open_cards_dialog(): + """Модул 'Карти' – списък на всички карти с (де)активиране.""" + win = tk.Toplevel(root) + win.title("Карти") + win.transient(root) + win.grab_set() + win.configure(bg="#FFFDE7") + root.update_idletasks() + rw = root.winfo_width() + rh = root.winfo_height() + w_win = max(520, min(rw - 80, 920)) + h_win = max(400, min(rh - 80, 620)) + win.geometry(f"{w_win}x{h_win}") + + frame = tk.Frame(win, bg="#FFFDE7", padx=12, pady=12) + frame.pack(fill=tk.BOTH, expand=True) + + # Таблица като решетка: сиво очертаване (не черно) + cell_bg = "#FFFDE7" + header_bg = "#F5F0D7" + selected_bg = "#E8E0C8" + table_border_gray = "#9CA3AF" + col_widths = (18, 32, 10, 18, 18) # ширина в знаци + + canvas_wrap = tk.Canvas(frame, bg=cell_bg, highlightthickness=0) + scroll_cards = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas_wrap.yview) + border_frame = tk.Frame(canvas_wrap, bg=table_border_gray, padx=1, pady=1) + table_frame = tk.Frame(border_frame, bg=cell_bg) + table_frame.pack(fill=tk.BOTH, expand=True) + table_frame.bind( + "", + lambda e: canvas_wrap.configure(scrollregion=canvas_wrap.bbox("all")), + ) + canvas_wrap.create_window((0, 0), window=border_frame, anchor="nw") + canvas_wrap.configure(yscrollcommand=scroll_cards.set) + + def _on_canvas_configure(event): + for item in canvas_wrap.find_all(): + canvas_wrap.itemconfig(item, width=event.width) + + canvas_wrap.bind("", _on_canvas_configure) + + cards_data = [] + selected_row_index = [None] # list to allow assign in nested func + + def fmt_dt_short(dt): + if not dt: + return "" + try: + if hasattr(dt, "strftime"): + return dt.strftime("%d.%m.%Y %H:%M") + s = str(dt) + return s.split(".")[0].replace("T", " ") + except Exception: + return str(dt) + + def clear_table(): + for w in table_frame.grid_slaves(): + w.destroy() + + def on_select_row(idx): + selected_row_index[0] = idx + rebuild_table() + + def rebuild_table(): + clear_table() + # Ред 0: заглавки – всяка клетка в сива рамка (не черна) + headers = ("Код", "Текст (име + № кола)", "Активна", "Създадена на", "Деактивирана на") + for c, (text, wch) in enumerate(zip(headers, col_widths)): + cell_f = tk.Frame(table_frame, bg=table_border_gray, padx=1, pady=1) + cell_f.grid(row=0, column=c, sticky="nsew", padx=0, pady=0) + lbl = tk.Label( + cell_f, + text=text, + width=wch, + font=("Segoe UI", 9, "bold"), + bg=header_bg, + anchor="center", + ) + lbl.pack(fill=tk.BOTH, expand=True) + table_frame.columnconfigure(1, weight=1) + + for r_idx, r in enumerate(cards_data): + deact_date = "" + if not r["is_active"] and r.get("updated_at"): + deact_date = fmt_dt_short(utc_to_local_for_display(r["updated_at"])) + row_bg = selected_bg if selected_row_index[0] == r_idx else cell_bg + vals = ( + r["code"], + r["display_text"], + "Да" if r["is_active"] else "Не", + fmt_dt_short(utc_to_local_for_display(r["created_at"])), + deact_date, + ) + for c, (val, wch) in enumerate(zip(vals, col_widths)): + cell_f = tk.Frame(table_frame, bg=table_border_gray, padx=1, pady=1) + cell_f.grid(row=r_idx + 1, column=c, sticky="nsew", padx=0, pady=0) + lbl = tk.Label( + cell_f, + text=val or "", + width=wch, + font=("Segoe UI", 9), + bg=row_bg, + anchor="w" if c != 2 else "center", + ) + lbl.pack(fill=tk.BOTH, expand=True) + cell_f.bind("", lambda e, i=r_idx: on_select_row(i)) + lbl.bind("", lambda e, i=r_idx: on_select_row(i)) + + def reload_cards(): + cards_data.clear() + selected_row_index[0] = None + try: + rows = load_cards(conn) + except Exception as ex: + _msg_centered(win, "Грешка", f"Грешка при зареждане на карти:\n{ex}") + return + for r in rows: + cards_data.append(r) + rebuild_table() + + def get_selected_card(): + idx = selected_row_index[0] + if idx is None or idx < 0 or idx >= len(cards_data): + _msg_centered(win, "Информация", "Изберете карта от списъка (клик върху ред).") + return None + return cards_data[idx] + + def on_deactivate_card(): + card = get_selected_card() + if not card: + return + if not card["is_active"]: + _msg_centered(win, "Информация", "Картата вече е деактивирана.") + return + if not _ask_yesno_centered( + win, + "Потвърждение", + "Деактивирате ли тази карта?\n\nСлед деактивация тя няма да може да се използва за регистрация.", + ): + return + try: + set_card_active(conn, card["id"], False) + _msg_centered(win, "Готово", "Картата е деактивирана.") + reload_cards() + refresh() + except Exception as ex: + _msg_centered(win, "Грешка", f"Грешка при деактивиране:\n{ex}") + + def on_activate_card(): + card = get_selected_card() + if not card: + return + if card["is_active"]: + _msg_centered(win, "Информация", "Картата вече е активна.") + return + if not _ask_yesno_centered( + win, + "Потвърждение", + "Активирате ли тази карта отново?", + ): + return + try: + set_card_active(conn, card["id"], True) + _msg_centered(win, "Готово", "Картата е активирана отново.") + reload_cards() + refresh() + except Exception as ex: + _msg_centered(win, "Грешка", f"Грешка при активиране:\n{ex}") + + def create_card_png(): + card = get_selected_card() + if not card: + return + path = filedialog.asksaveasfilename( + title="Запази карта като PNG", + defaultextension=".png", + filetypes=[("PNG изображение", "*.png"), ("Всички файлове", "*.*")], + initialfile=f"karta_{card['code']}.png", + ) + if not path: + return + try: + import qrcode + from PIL import Image, ImageDraw, ImageFont + code = str(card["code"]) + text = str(card["display_text"] or "") + # Размер като визитка (прибл. 90×54 mm при 300 dpi), QR колкото може по-голям + w, h = 1063, 638 # ~90×54 mm @ 300 dpi + img = Image.new("RGB", (w, h), "white") + draw = ImageDraw.Draw(img) + draw.rectangle([(0, 0), (w - 1, h - 1)], outline="black", width=2) + margin = 16 + # QR заема максимално височината на картата + qr_size = h - 2 * margin + qr = qrcode.QRCode(version=1, box_size=10, border=2) + qr.add_data(code) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + qr_img = qr_img.resize((qr_size, qr_size)) + qr_x = w - margin - qr_size + qr_y = margin + img.paste(qr_img, (qr_x, qr_y)) + # Текст вляво – компактен + try: + font_code = ImageFont.truetype("arial.ttf", 26) + font_label = ImageFont.truetype("arial.ttf", 14) + font_text = ImageFont.truetype("arial.ttf", 20) + except Exception: + font_code = font_label = font_text = ImageFont.load_default() + tx, ty = margin, margin + draw.text((tx, ty), f"Код: {code}", fill="black", font=font_code) + ty += 38 + draw.text((tx, ty), "Име / № кола:", fill="gray", font=font_label) + ty += 22 + max_chars = max(12, (qr_x - tx - 12) // 12) + for line in (text[i : i + max_chars] for i in range(0, len(text), max_chars)): + draw.text((tx, ty), line, fill="black", font=font_text) + ty += 26 + img.save(path) + win.update_idletasks() + _msg_centered(win, "Готово", f"Файлът е запазен:\n{path}") + # Отваряне на файла с програмата по подразбиране за преглед + try: + path_abs = os.path.abspath(path) + if platform.system() == "Windows": + os.startfile(path_abs) + elif platform.system() == "Darwin": + subprocess.run(["open", path_abs], check=False) + else: + subprocess.run(["xdg-open", path_abs], check=False) + except Exception: + pass + except Exception as ex: + win.update_idletasks() + _msg_centered(win, "Грешка", f"Неуспешно създаване на PNG:\n{ex}") + + def on_edit_card(): + card = get_selected_card() + if not card: + return + edit_win = tk.Toplevel(win) + edit_win.title("Коригиране на карта") + edit_win.transient(win) + edit_win.grab_set() + f_edit = ttk.Frame(edit_win, padding=12) + f_edit.pack(fill=tk.BOTH, expand=True) + ttk.Label(f_edit, text="Код (ID):").grid(row=0, column=0, sticky="w", pady=2) + ttk.Label(f_edit, text=card["code"]).grid(row=0, column=1, padx=8, pady=2, sticky="w") + ttk.Label(f_edit, text="Текст (име + № кола):").grid(row=1, column=0, sticky="w", pady=2) + text_ent = ttk.Entry(f_edit, width=40) + text_ent.insert(0, card["display_text"] or "") + text_ent.grid(row=1, column=1, padx=8, pady=2, sticky="ew") + f_edit.columnconfigure(1, weight=1) + + def do_save(): + new_text = text_ent.get().strip() + if not new_text: + _msg_centered(edit_win, "Внимание", "Въведете текст на картата.") + return + try: + update_card(conn, card["id"], new_text) + _msg_centered(edit_win, "Готово", "Картата е коригирана.") + edit_win.destroy() + reload_cards() + refresh() + except Exception as ex: + _msg_centered(edit_win, "Грешка", str(ex)) + + btn_row = ttk.Frame(f_edit) + btn_row.grid(row=2, column=0, columnspan=2, pady=(12, 0)) + ttk.Button(btn_row, text="Запази", command=do_save).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_row, text="Отказ", command=edit_win.destroy).pack(side=tk.LEFT, padx=4) + _center_on_parent(edit_win, win) + text_ent.focus_set() + + def on_delete_card(): + card = get_selected_card() + if not card: + return + if not _ask_yesno_centered( + win, + "Потвърждение", + f"Изтриване на карта?\n\nКод: {card['code']}\nТекст: {card['display_text']}\n\nЩе бъдат изтрити и всички регистрации по тази карта.", + ): + return + try: + delete_card(conn, card["id"]) + _msg_centered(win, "Готово", "Картата е изтрита.") + reload_cards() + refresh() + except Exception as ex: + _msg_centered(win, "Грешка", f"Грешка при изтриване:\n{ex}") + + canvas_wrap.grid(row=0, column=0, sticky="nsew") + scroll_cards.grid(row=0, column=1, sticky="ns") + frame.rowconfigure(0, weight=1) + frame.columnconfigure(0, weight=1) + + btn_bar = ttk.Frame(frame) + btn_bar.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + ttk.Button(btn_bar, text="Обнови", command=reload_cards).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Коригирай", command=on_edit_card).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Изтрий", command=on_delete_card).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Деактивирай", command=on_deactivate_card).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Активирай", command=on_activate_card).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Създай PNG", command=create_card_png).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Генерирай QR карта", command=open_qr_dialog).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_bar, text="Затвори", command=win.destroy).pack(side=tk.RIGHT, padx=4) + + reload_cards() + _center_on_parent(win, root) + + def on_close_cycle(): + nonlocal selected_row_index + if selected_row_index is None or selected_row_index >= len(cycles_data): + root.update_idletasks() + _msg_centered(root, "Информация", "Изберете ред от списъка (клик с мишката).") + return + row = cycles_data[selected_row_index] + root.update_idletasks() + if not _ask_yesno_centered(root, "Потвърждение", "Служебно затваряне на цикъла за тази карта?"): + return + try: + updated = close_cycle_manually(conn, row["cycle_id"]) + root.update_idletasks() + if updated: + _msg_centered(root, "Готово", "Цикълът е затворен. Записан е Служебен изход.") + else: + _msg_centered(root, "Информация", "Цикълът вече е затворен служебно.") + refresh() + except Exception as e: + root.update_idletasks() + _msg_centered(root, "Грешка", f"Неуспешно затваряне:\n{e}") + + def on_undo_service_exit(): + nonlocal selected_row_index + if selected_row_index is None or selected_row_index >= len(cycles_data): + root.update_idletasks() + _msg_centered(root, "Информация", "Изберете ред от списъка (клик с мишката).") + return + row = cycles_data[selected_row_index] + if not row.get("service_exit"): + root.update_idletasks() + _msg_centered(root, "Информация", "Изберете цикъл със служебен изход за отмяна.") + return + root.update_idletasks() + if not _ask_yesno_centered(root, "Потвърждение", "Да се премахне служебният изход за този цикъл?"): + return + try: + removed = undo_manual_cycle_close(conn, row["cycle_id"]) + root.update_idletasks() + if removed: + _msg_centered(root, "Готово", "Служебният изход е отменен.") + else: + _msg_centered(root, "Информация", "Записът за служебен изход вече липсва.") + refresh() + except Exception as e: + root.update_idletasks() + _msg_centered(root, "Грешка", f"Неуспешна отмяна:\n{e}") + + # бутони и статус + btn_frame = ttk.Frame(root) + ttk.Button(btn_frame, text="Обнови", command=refresh).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Затвори цикъла", command=on_close_cycle).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Отмени служебен изход", command=on_undo_service_exit).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Карти", command=open_cards_dialog).pack(side=tk.LEFT, padx=12) + status_label = ttk.Label(btn_frame, text="") + status_label.pack(side=tk.LEFT, padx=12) + + # подредба + canvas.grid(row=3, column=0, sticky="nsew") + scroll_y.grid(row=3, column=1, sticky="ns") + btn_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=8, padx=4) + + root.grid_rowconfigure(3, weight=1) + root.grid_columnconfigure(0, weight=1) + + refresh() + root.mainloop() + try: + conn.close() + except Exception: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/make_icon.py b/make_icon.py new file mode 100644 index 0000000..d83dc61 --- /dev/null +++ b/make_icon.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PIL import Image + + +def build_square_ico(src: Path, dst: Path) -> None: + im = Image.open(src).convert("RGBA") + w, h = im.size + s = max(w, h) + square = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + square.paste(im, ((s - w) // 2, (s - h) // 2)) + square = square.resize((256, 256), Image.LANCZOS) + + # A single 256x256 icon is sufficient for modern Windows; smaller sizes + # will be auto-derived by the shell if needed. + square.save(dst, format="ICO") + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print("Usage: python make_icon.py ", file=sys.stderr) + return 2 + + src = Path(argv[1]).resolve() + dst = Path(argv[2]).resolve() + + if not src.exists(): + print(f"Icon source not found: {src}", file=sys.stderr) + return 2 + + build_square_ico(src, dst) + print(f"Wrote: {dst}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc1ff6a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyodbc>=5.0.0 +qrcode[pil]>=7.4.0 diff --git a/reset_manual_cycle_closes.sql b/reset_manual_cycle_closes.sql new file mode 100644 index 0000000..e05fbeb --- /dev/null +++ b/reset_manual_cycle_closes.sql @@ -0,0 +1,5 @@ +-- Нулиране на всички служебни изходи (нулиране на sl_close, dat_close, cycle_closed_at в ITD_Cards). + +UPDATE dbo.ITD_Cards +SET sl_close = NULL, dat_close = NULL, cycle_closed_at = NULL +WHERE sl_close IS NOT NULL OR dat_close IS NOT NULL OR cycle_closed_at IS NOT NULL; diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..b44b78a --- /dev/null +++ b/schema.sql @@ -0,0 +1,37 @@ +-- ITD Transport Tracking - SQL Server schema +-- Таблици: карти (QR), цикли с 5 поста (дата-час за всеки пост) + служебно затваряне + +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ITD_Cards') +BEGIN + CREATE TABLE dbo.ITD_Cards ( + Id INT IDENTITY(1,1) NOT NULL PRIMARY KEY, + Code NVARCHAR(128) NOT NULL UNIQUE, + DisplayText NVARCHAR(256) NOT NULL, + IsActive BIT NOT NULL DEFAULT 1, + CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() + ); + CREATE INDEX IX_ITD_Cards_Code ON dbo.ITD_Cards(Code); + CREATE INDEX IX_ITD_Cards_IsActive ON dbo.ITD_Cards(IsActive); +END +GO + +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ITD_Cycles') +BEGIN + CREATE TABLE dbo.ITD_Cycles ( + Id INT IDENTITY(1,1) NOT NULL PRIMARY KEY, + CardId INT NOT NULL, + CycleNo INT NOT NULL, + Post1At DATETIME2 NOT NULL, + Post2At DATETIME2 NULL, + Post3At DATETIME2 NULL, + Post4At DATETIME2 NULL, + Post5At DATETIME2 NULL, + ServiceClosed BIT NOT NULL DEFAULT 0, + ServiceClosedAt DATETIME2 NULL, + CONSTRAINT FK_ITD_Cycles_Cards FOREIGN KEY (CardId) REFERENCES dbo.ITD_Cards(Id) + ); + CREATE INDEX IX_ITD_Cycles_CardId ON dbo.ITD_Cycles(CardId); + CREATE INDEX IX_ITD_Cycles_Post1At ON dbo.ITD_Cycles(Post1At); +END +GO diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..00f7df5 --- /dev/null +++ b/start.bat @@ -0,0 +1,26 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +echo ITD Transport Tracking - старт +echo. + +if not exist "appsettings.json" ( + echo Грешка: липсва appsettings.json + pause + exit /b 1 +) + +pip install -r requirements.txt -q +python db_check.py +if errorlevel 1 ( + echo. + pause + exit /b 1 +) + +echo Стартиране на основния екран... +python main.py +echo. +echo Готово. +pause