commit 2fa5fbca3915ba555e3c7dbfe2902cc0dbf9c598 Author: Sabo Sabev Date: Sat Mar 21 16:11:47 2026 +0200 First 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 0000000..f3af19c Binary files /dev/null and b/ITD.ico differ diff --git a/ITD_desc.txt b/ITD_desc.txt new file mode 100644 index 0000000..f697016 --- /dev/null +++ b/ITD_desc.txt @@ -0,0 +1,33 @@ +ITD Transport Tracking + +Всяко транспортно средство (камион) има карта с баркод (QR код). = код + текст +Минава се през 5 точки за регистрация + 1 портал - камиона е на паркинга + 2 вход в завода + 3 рампа начало товарене + 4 рампа край на товарене + 5 напускане територията на завода + +Т.е. има няколко терминала - портал и рампа, където се прави регистрацията, +всяко четене на картата е регистрация на следващия пост. + +Основен екран колони : + - карта текст (номер камион) + - дата - час на регистрация + - време между регистрацията влизането + - време между постовете + - последна колона = време на излизане + - последна колона за служебно затваряне на цикъла, ако липсва регистрация на последен пост + +База данни на SQL Server --- appsettings.json + +Може да е на Python, да е преносимо EXE на различни компютри без излишни инсталации. + +Да има административен модул, който да отпечатва карти с QR kod + допълнително въведен текст и +администриране на картите + - корекция на име + номер кола + - отпечатване на карта + - активиране / деактивиране +Текста съдържа име на човек + номер камион (кола). + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5c0c9e --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# ITD Transport Tracking – пълно ръководство + +Приложение за проследяване на транспортни средства (камиони) по постове: четене на QR карта, регистрация на преминаване през постове, генериране на карти и администрация на картите. Работи с база данни SQL Server и може да се ползва като Python скрипт или като преносимо EXE. + +--- + +## 1. Какво прави приложението + +- **Регистрация по пост** – при четене на QR код на карта се записва преминаване през следващия пост (1→2→3→4→5). +- **Пет поста:** Портал (паркинг) → Вход в завода → Рампа начало товарене → Рампа край товарене → Напускане. +- **Таблица с цикли** – показва всички цикли с време за всеки пост и изход; поддържа филтър по номер на карта. +- **Служебно затваряне на цикъл** – ако липсва регистрация на последен пост, цикълът може да се затвори ръчно със „Служебен изход“ (и после отмяна на служебния изход). +- **Модул „Карти“** – списък на карти, корекция на текст (име + № кола), активиране/деактивиране, генериране на QR карта, отпечатване на карта като PNG. + +Иконата на приложението е **ITD.ico** (квадратна, без допълнително преобразуване). + +--- + +## 2. Изисквания + +- **Python 3.8+** (при ползване като скрипт). +- **SQL Server** – база данни с таблиците `ITD_Cards` и `ITD_Cycles` (схемата се прилага автоматично при първо стартиране от `schema.sql`). +- **ODBC Driver 17 for SQL Server** (или съвместим) на машината. +- За изграждане на EXE: **PyInstaller** (`pip install pyinstaller`). + +--- + +## 3. Първоначална настройка + +### 3.1 Файлове в папката на проекта + +| Файл | Описание | +|------|----------| +| `main.py` | Основно приложение (екран с регистрация, цикли, бутони). | +| `db.py` | Връзка към БД и операции с карти/цикли. | +| `db_check.py` | Проверка за достъп до БД и прилагане на `schema.sql`. | +| `schema.sql` | SQL схема – таблици `ITD_Cards`, `ITD_Cycles`. | +| `appsettings.json` | **Задължителен** – connection string към SQL Server (не се комитва в Git). | +| `appsettings.example.json` | Пример; копирайте като `appsettings.json` и попълнете данните. | +| `ITD.ico` | Икона на приложението (прозорец и EXE). | +| `start.bat` | Стартиране в режим разработка (проверка БД + main.py). | +| `build.bat` | Изграждане на преносимо EXE. | +| `requirements.txt` | Python зависимости: pyodbc, qrcode[pil]. | + +### 3.2 Настройка на връзката към базата данни + +1. Копирайте `appsettings.example.json` като **`appsettings.json`** в същата папка. +2. Отворете `appsettings.json` и попълнете connection string: + +```json +{ + "ConnectionStrings": { + "SqlServer": "Server=ВАШИЯ_СЪРВЪР;Database=ITD;User Id=ПОТРЕБИТЕЛ;Password=ПАРОЛА;TrustServerCertificate=true" + } +} +``` + +- **Server** – име или адрес на SQL Server инстанцията. +- **Database** – име на базата (напр. `ITD`). +- **User Id** / **Password** – потребител с права за създаване на таблици и четене/запис. +- **TrustServerCertificate=true** – обикновено нужен при самоподписан сертификат. + +Алтернативно можете да използвате **Odbc** ключ в `ConnectionStrings` с пълен ODBC connection string, ако не ползвате SqlServer формата. + +### 3.3 База данни и таблици + +При първо стартиране (или при изпълнение на `db_check.py`) приложението търси `schema.sql` и изпълнява скрипта. Така се създават: + +- **ITD_Cards** – карти (код, текст за показ, активна/неактивна, дати). +- **ITD_Cycles** – цикли за всяка карта с дата/час за пост 1…5 и служебно затваряне. + +Ако таблиците вече съществуват, схемата не променя данните (използва се `IF NOT EXISTS`). + +--- + +## 4. Стартиране (режим разработка) + +1. Отворете папката на проекта в командния ред. +2. Стартирайте: + +```bat +start.bat +``` + +Скриптът: + +- проверява за наличие на `appsettings.json`; +- инсталира зависимости от `requirements.txt` (ако липсват); +- пуска `db_check.py` – проверка за достъп до БД и прилагане на схемата; +- при успех пуска `main.py`. + +Ако няма `appsettings.json` или връзката към БД не успее, програмата спира с подходящо съобщение. + +Можете да стартирате и директно: + +```bat +python db_check.py +python main.py +``` + +Иконата на прозореца се зарежда от **ITD.ico** в същата папка. + +--- + +## 5. Изграждане на преносимо EXE + +1. Уверете се, че в папката има: `main.py`, `schema.sql`, **ITD.ico**, `appsettings.json` (за тест; за разпространение вижте по-долу). +2. Инсталирайте PyInstaller (еднократно): + +```bat +pip install pyinstaller +``` + +3. Пуснете: + +```bat +build.bat +``` + +При успех в подпапка **`dist`** се създава **`ITD_Transport.exe`**. В EXE е вградена и схемата `schema.sql`; иконата на изпълнимия файл е **ITD.ico**. + +Ако няма `ITD.ico`, build-ът все пак минава, но EXE няма да има икона. + +--- + +## 6. Преносимо ползване на друг компютър + +За да ползвате приложението без инсталиран Python: + +1. Копирайте в една папка на целевата машина: + - **ITD_Transport.exe** (от `dist\`); + - **appsettings.json** (с правилен connection string за тази мрежа/сървър); + - **ITD.ico** (по желание – за икона на прозореца при нужда). +2. На целевата машина трябва да е инсталиран **ODBC Driver 17 for SQL Server** (или съвместим драйвер). +3. Стартирайте `ITD_Transport.exe`. Работната директория е папката на exe-то; от там се чете `appsettings.json`. + +Не е нужно да инсталирате Python или PyInstaller на целевата машина. + +--- + +## 7. Основни екрани и функции + +### 7.1 Основен екран + +- **Постове** – показват се петте поста с цветове. +- **QR карта (код)** – въвеждате или сканирате кода на картата и натискате **Регистрирай**. Записва се следващият пост за тази карта. +- **Филтър по карта** – показват се само циклите за въведения номер на карта; **Всички** премахва филтъра. +- **Таблица с цикли** – ред за всеки цикъл: карта, време за постове 1–5, изход (нормален или служебен). С клик се избира ред. +- **Бутони:** + - **Обнови** – презарежда циклите; + - **Затвори цикъла** – служебно затваряне на избрания цикъл (ако липсва регистрация на последен пост); + - **Отмени служебен изход** – премахва служебното затваряне за избрания цикъл; + - **Карти** – отваря модула за карти. + +### 7.2 Модул „Карти“ + +- Таблица с всички карти: код, текст (име + № кола), активна, дати. +- **Обнови** – презарежда списъка. +- **Коригирай** – промяна на текста на избрана карта. +- **Изтрий** – изтриване на карта (и регистрациите по нея). +- **Деактивирай** / **Активирай** – картата спира/започва да участва в регистрации. +- **Създай PNG** – запазване на карта като PNG (QR + текст) за отпечатване. +- **Генерирай QR карта** – нова карта с текст; запис в БД и показване на QR за отпечатване. +- **Затвори** – затваряне на прозореца. + +--- + +## 8. Git и синхронизация + +За работа с няколко компютъра и GitHub вижте **GIT_SETUP.md**: инициализация на repo, push към GitHub, клониране на друг компютър и локално изграждане на EXE. + +**Важно:** `appsettings.json` е в `.gitignore` – не се комитва. На всеки компютър копирайте `appsettings.example.json` като `appsettings.json` и попълнете connection string. + +--- + +## 9. Помощни скриптове и файлове + +- **make_icon.py** – не се използва от текущия build; иконата е директно **ITD.ico**. +- **reset_manual_cycle_closes.sql** – при нужда за нулиране на служебни затваряния (изпълнява се ръчно върху БД). +- **centered_messagebox.py** – помощни функции за центрирани диалози в приложението. + +--- + +## 10. Обобщение – бърз старт + +| Действие | Команда / стъпка | +|----------|-------------------| +| Първа настройка | Копирайте `appsettings.example.json` → `appsettings.json`, попълнете connection string. | +| Стартиране | `start.bat` (или `python db_check.py` и `python main.py`). | +| Build на EXE | `build.bat` → exe в `dist\ITD_Transport.exe`. | +| Преносимо ползване | Копирайте exe + `appsettings.json` + при желание `ITD.ico` в папка на целевата машина; инсталиран ODBC драйвер за SQL Server. | + +Иконата на приложението е само **ITD.ico** – в папката на скрипта при разработка и в папката на exe при преносимо ползване. diff --git a/appsettings.example.json b/appsettings.example.json new file mode 100644 index 0000000..f8a4809 --- /dev/null +++ b/appsettings.example.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "SqlServer": "Server=ВАШИЯ_СЪРВЪР;Database=ITD;User Id=ПОТРЕБИТЕЛ;Password=ПАРОЛА;TrustServerCertificate=true" + } +} diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..3ba01fe --- /dev/null +++ b/build.bat @@ -0,0 +1,49 @@ +@echo off +chcp 65001 >nul +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