main
Sabo Sabev 2 weeks ago
commit 2fa5fbca39

28
.gitignore vendored

@ -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

@ -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-вате локално на избраната машина.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@ -0,0 +1,33 @@
ITD Transport Tracking
Всяко транспортно средство (камион) има карта с баркод (QR код). = код + текст
Минава се през 5 точки за регистрация
1 портал - камиона е на паркинга
2 вход в завода
3 рампа начало товарене
4 рампа край на товарене
5 напускане територията на завода
Т.е. има няколко терминала - портал и рампа, където се прави регистрацията,
всяко четене на картата е регистрация на следващия пост.
Основен екран колони :
- карта текст (номер камион)
- дата - час на регистрация
- време между регистрацията влизането
- време между постовете
- последна колона = време на излизане
- последна колона за служебно затваряне на цикъла, ако липсва регистрация на последен пост
База данни на SQL Server --- appsettings.json
Може да е на Python, да е преносимо EXE на различни компютри без излишни инсталации.
Да има административен модул, който да отпечатва карти с QR kod + допълнително въведен текст и
администриране на картите
- корекция на име + номер кола
- отпечатване на карта
- активиране / деактивиране
Текста съдържа име на човек + номер камион (кола).

@ -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 карта (код)** въвеждате или сканирате кода на картата и натискате **Регистрирай**. Записва се следващият пост за тази карта.
- **Филтър по карта** показват се само циклите за въведения номер на карта; **Всички** премахва филтъра.
- **Таблица с цикли** ред за всеки цикъл: карта, време за постове 15, изход (нормален или служебен). С клик се избира ред.
- **Бутони:**
- **Обнови** презарежда циклите;
- **Затвори цикъла** служебно затваряне на избрания цикъл (ако липсва регистрация на последен пост);
- **Отмени служебен изход** премахва служебното затваряне за избрания цикъл;
- **Карти** отваря модула за карти.
### 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 при преносимо ползване.

@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"SqlServer": "Server=ВАШИЯ_СЪРВЪР;Database=ITD;User Id=ПОТРЕБИТЕЛ;Password=ПАРОЛА;TrustServerCertificate=true"
}
}

@ -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

@ -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

510
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):
"""Добавя регистрация за карта на даден пост (15). Пост 1 = нов ред в ITD_Cycles; постове 25 = 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):
"""Връща последния попълнен пост (15) за дадена карта от последния цикъл, или 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):
"""Връща следващия пост за регистрация (15). След служебно затваряне или пост 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("Картата не е намерена.")

@ -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())

@ -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())

@ -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):
"""Добавя регистрация за карта на даден пост (15). Пост 1 = нов ред в ITD_Cycles; постове 25 = 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):
"""Връща последния попълнен пост (15) за дадена карта от последния цикъл, или 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):
"""Връща следващия пост за регистрация (15). След служебно затваряне или пост 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("Картата не е намерена.")

@ -0,0 +1 @@
[STARTUP] OK. appsettings=C:\~ip\app-dblib\cursor_projects\ITD-desktop\appsettings.json

@ -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("<Return>", 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("<Button-1>", on_canvas_click)
canvas.bind("<Configure>", 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(
"<Configure>",
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("<Configure>", _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("<Button-1>", lambda e, i=r_idx: on_select_row(i))
lbl.bind("<Button-1>", 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())

@ -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 <src.ico> <dst.ico>", 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))

@ -0,0 +1,2 @@
pyodbc>=5.0.0
qrcode[pil]>=7.4.0

@ -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;

@ -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

@ -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
Loading…
Cancel
Save