Spaces:
Running
Running
| from flask import Flask, render_template_string, request, redirect, url_for, flash, session | |
| import json | |
| import os | |
| import logging | |
| import threading | |
| import time | |
| from datetime import datetime, timedelta, timezone | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError | |
| from dotenv import load_dotenv | |
| import uuid | |
| import calendar | |
| load_dotenv() | |
| app = Flask(__name__) | |
| app.secret_key = 'employee_sys_secret_key' | |
| DATA_FILE = 'data.json' | |
| SYNC_FILES = [DATA_FILE] | |
| REPO_ID = os.getenv("REPO_ID", "Kgshop/kopeika") | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| DOWNLOAD_RETRIES = 3 | |
| DOWNLOAD_DELAY = 5 | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): | |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE | |
| files_to_download = [specific_file] if specific_file else SYNC_FILES | |
| all_successful = True | |
| for file_name in files_to_download: | |
| success = False | |
| for attempt in range(retries + 1): | |
| try: | |
| hf_hub_download( | |
| repo_id=REPO_ID, | |
| filename=file_name, | |
| repo_type="dataset", | |
| token=token_to_use, | |
| local_dir=".", | |
| local_dir_use_symlinks=False, | |
| force_download=True, | |
| resume_download=False | |
| ) | |
| success = True | |
| break | |
| except RepositoryNotFoundError: | |
| return False | |
| except HfHubHTTPError as e: | |
| if e.response.status_code == 404: | |
| if attempt == 0 and not os.path.exists(file_name): | |
| try: | |
| if file_name == DATA_FILE: | |
| with open(file_name, 'w', encoding='utf-8') as f: | |
| json.dump({'employees': [], 'attendance': {}, 'adjustments': [], 'settings': {'workday_start_time': '09:00'}}, f) | |
| except Exception: | |
| pass | |
| success = False | |
| break | |
| except Exception: | |
| pass | |
| if attempt < retries: | |
| time.sleep(delay) | |
| if not success: | |
| all_successful = False | |
| return all_successful | |
| def upload_db_to_hf(specific_file=None): | |
| if not HF_TOKEN_WRITE: | |
| return | |
| try: | |
| api = HfApi() | |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES | |
| for file_name in files_to_upload: | |
| if os.path.exists(file_name): | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=file_name, | |
| path_in_repo=file_name, | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE, | |
| commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" | |
| ) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| def periodic_backup(): | |
| backup_interval = 1800 | |
| while True: | |
| time.sleep(backup_interval) | |
| upload_db_to_hf() | |
| def load_data(): | |
| default_data = {'employees': [], 'attendance': {}, 'adjustments': [], 'settings': {'workday_start_time': '09:00'}} | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: | |
| data = json.load(file) | |
| if not isinstance(data, dict): | |
| raise FileNotFoundError | |
| if 'employees' not in data: data['employees'] = [] | |
| if 'attendance' not in data: data['attendance'] = {} | |
| if 'adjustments' not in data: data['adjustments'] = [] | |
| if 'settings' not in data: data['settings'] = {'workday_start_time': '09:00'} | |
| return data | |
| except FileNotFoundError: | |
| pass | |
| if download_db_from_hf(specific_file=DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: | |
| data = json.load(file) | |
| if not isinstance(data, dict): | |
| return default_data | |
| if 'employees' not in data: data['employees'] = [] | |
| if 'attendance' not in data: data['attendance'] = {} | |
| if 'adjustments' not in data: data['adjustments'] = [] | |
| if 'settings' not in data: data['settings'] = {'workday_start_time': '09:00'} | |
| return data | |
| except Exception: | |
| return default_data | |
| else: | |
| if not os.path.exists(DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(default_data, f) | |
| except Exception: | |
| pass | |
| return default_data | |
| def save_data(data): | |
| try: | |
| if not isinstance(data, dict): | |
| return | |
| if 'employees' not in data: data['employees'] = [] | |
| if 'attendance' not in data: data['attendance'] = {} | |
| if 'adjustments' not in data: data['adjustments'] = [] | |
| if 'settings' not in data: data['settings'] = {'workday_start_time': '09:00'} | |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: | |
| json.dump(data, file, ensure_ascii=False, indent=4) | |
| upload_db_to_hf(specific_file=DATA_FILE) | |
| except Exception: | |
| pass | |
| def get_almaty_date(): | |
| almaty_tz = timezone(timedelta(hours=5)) | |
| return datetime.now(almaty_tz).strftime('%Y-%m-%d') | |
| BASE_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Учет сотрудников</title> | |
| <style> | |
| body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #fff; color: #000; margin: 0; padding: 0; line-height: 1.6; } | |
| .nav { background: #000; color: #fff; padding: 15px 20px; display: flex; gap: 20px; flex-wrap: wrap; } | |
| .nav a { color: #fff; text-decoration: none; font-weight: bold; text-transform: uppercase; font-size: 14px; letter-spacing: 1px; } | |
| .nav a:hover { text-decoration: underline; } | |
| .container { max-width: 1200px; margin: 30px auto; padding: 0 20px; } | |
| h2, h3 { border-bottom: 2px solid #000; padding-bottom: 5px; margin-bottom: 20px; text-transform: uppercase; } | |
| table { width: 100%; border-collapse: collapse; margin-top: 20px; margin-bottom: 30px; } | |
| th, td { border: 1px solid #000; padding: 12px; text-align: left; } | |
| th { background: #000; color: #fff; text-transform: uppercase; font-size: 13px; } | |
| tr:nth-child(even) { background-color: #f9f9f9; } | |
| .btn { background: #000; color: #fff; padding: 10px 20px; border: 2px solid #000; cursor: pointer; text-decoration: none; display: inline-block; font-size: 14px; font-weight: bold; transition: all 0.2s; text-transform: uppercase;} | |
| .btn:hover { background: #fff; color: #000; } | |
| .btn-danger { background: #fff; color: #000; border: 2px solid #000; } | |
| .btn-danger:hover { background: #000; color: #fff; } | |
| input[type="text"], input[type="number"], input[type="date"], input[type="time"], select { padding: 10px; margin: 5px 0 15px 0; border: 1px solid #000; box-sizing: border-box; width: 100%; font-family: inherit; font-size: 14px; } | |
| input:focus, select:focus { outline: none; border-width: 2px; } | |
| .search-input { padding: 12px; margin: 0 0 20px 0; border: 2px solid #000; box-sizing: border-box; width: 100%; font-size: 14px; font-weight: bold; background-color: #fafafa; } | |
| .search-input:focus { background-color: #fff; outline: none; } | |
| label { font-weight: bold; display: block; text-transform: uppercase; font-size: 12px; } | |
| .alert { padding: 15px; border: 2px solid #000; margin-bottom: 20px; background: #fff; font-weight: bold; text-transform: uppercase; font-size: 13px; } | |
| .grid-2 { display: grid; grid-template-columns: 1fr; gap: 30px; } | |
| @media (min-width: 768px) { .grid-2 { grid-template-columns: 1fr 1fr; } } | |
| .radio-group { display: flex; gap: 15px; align-items: center; flex-wrap: wrap; } | |
| .radio-group label { display: flex; align-items: center; gap: 5px; font-weight: normal; text-transform: none; font-size: 14px; cursor: pointer; margin: 0; padding: 5px 0;} | |
| .radio-group input[type="radio"] { margin: 0; cursor: pointer; width: 18px; height: 18px; } | |
| form { max-width: 100%; } | |
| .form-container { border: 1px solid #000; padding: 20px; background: #fafafa; } | |
| .table-responsive { overflow-x: auto; -webkit-overflow-scrolling: touch; width: 100%; } | |
| .cal-table-wrapper { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid #000; border-top: none; } | |
| .cal-table { margin-top: 0; margin-bottom: 0; white-space: nowrap; border: none; } | |
| .cal-table th, .cal-table td { padding: 8px 5px; text-align: center; border: 1px solid #ddd; min-width: 25px; font-size: 12px; } | |
| .cal-table th { background: #eee; color: #000; border-top: 1px solid #000; } | |
| .cal-table th:first-child, .cal-table td:first-child { text-align: left; position: sticky; left: 0; background: #fff; border-right: 2px solid #000; font-weight: bold; z-index: 2; } | |
| .cal-table th:first-child { background: #000; color: #fff; z-index: 3; border-top: 1px solid #000; } | |
| .cell-work { background-color: #4CAF50; color: #fff; } | |
| .cell-off { background-color: #F44336; color: #fff; } | |
| .cell-intern { background-color: #2196F3; color: #fff; } | |
| .cell-half { background-color: #FF9800; color: #fff; } | |
| .cell-none { background-color: #f1f1f1; color: #aaa; } | |
| .text-green { color: #4CAF50; font-weight: bold; } | |
| .text-red { color: #F44336; font-weight: bold; } | |
| @media (max-width: 600px) { | |
| .cal-table th, .cal-table td { padding: 5px 3px; font-size: 11px; min-width: 20px; } | |
| th, td { padding: 8px; font-size: 12px; } | |
| .radio-group { flex-direction: column; align-items: flex-start; gap: 5px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="nav"> | |
| <a href="/">Сотрудники</a> | |
| <a href="/attendance">Посещаемость</a> | |
| <a href="/finance">Финансы</a> | |
| <a href="/report">Отчеты</a> | |
| <a href="/settings">Настройки</a> | |
| </div> | |
| <div class="container"> | |
| {% with messages = get_flashed_messages() %} | |
| {% if messages %} | |
| {% for message in messages %} | |
| <div class="alert">{{ message }}</div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| {% block content %}{% endblock %} | |
| </div> | |
| <script> | |
| function filterTable(inputId, tableId) { | |
| var input = document.getElementById(inputId); | |
| var filter = input.value.toLowerCase(); | |
| var table = document.getElementById(tableId); | |
| var tr = table.getElementsByTagName("tr"); | |
| for (var i = 1; i < tr.length; i++) { | |
| var display = "none"; | |
| var td = tr[i].getElementsByTagName("td"); | |
| for (var j = 0; j < td.length; j++) { | |
| if (td[j]) { | |
| var txtValue = td[j].textContent || td[j].innerText; | |
| if (txtValue.toLowerCase().indexOf(filter) > -1) { | |
| display = ""; | |
| break; | |
| } | |
| } | |
| } | |
| tr[i].style.display = display; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| INDEX_TEMPLATE = """ | |
| <div class="grid-2"> | |
| <div> | |
| <h2>Добавить сотрудника</h2> | |
| <div class="form-container"> | |
| <form method="POST" action="/add_employee"> | |
| <label>ФИО</label> | |
| <input type="text" name="fio" required autocomplete="off"> | |
| <label>ИИН</label> | |
| <input type="text" name="iin" required autocomplete="off"> | |
| <label>Телефон</label> | |
| <input type="text" name="phone" required autocomplete="off"> | |
| <label>ЗП в день</label> | |
| <input type="number" name="daily_salary" step="0.01" required autocomplete="off"> | |
| <button type="submit" class="btn">Добавить</button> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <h2 style="margin-top: 40px;">Список сотрудников</h2> | |
| <input type="text" id="searchEmp" class="search-input" onkeyup="filterTable('searchEmp', 'empTable')" placeholder="Поиск по ФИО, ИИН или телефону..."> | |
| <div class="table-responsive"> | |
| <table id="empTable"> | |
| <tr> | |
| <th>ФИО</th> | |
| <th>ИИН</th> | |
| <th>Телефон</th> | |
| <th>ЗП в день</th> | |
| <th>Действия</th> | |
| </tr> | |
| {% for emp in employees %} | |
| <tr> | |
| <td><strong>{{ emp.fio }}</strong></td> | |
| <td>{{ emp.iin }}</td> | |
| <td>{{ emp.phone }}</td> | |
| <td>{{ emp.daily_salary }}</td> | |
| <td> | |
| <form method="POST" action="/delete_employee/{{ emp.id }}" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить сотрудника?');"> | |
| <button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;">Удалить</button> | |
| </form> | |
| </td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="5" style="text-align:center;">Сотрудников пока нет</td></tr> | |
| {% endfor %} | |
| </table> | |
| </div> | |
| """ | |
| ATTENDANCE_TEMPLATE = """ | |
| <h2>Посещаемость</h2> | |
| <div class="form-container" style="margin-bottom: 30px;"> | |
| <form method="GET" action="/attendance" style="display: flex; align-items: flex-end; gap: 15px; flex-wrap: wrap;"> | |
| <div style="flex-grow: 1; max-width: 250px;"> | |
| <label style="margin-bottom: 5px;">Выберите дату:</label> | |
| <input type="date" name="date" value="{{ selected_date }}" style="margin: 0;"> | |
| </div> | |
| <button type="submit" class="btn">Загрузить</button> | |
| </form> | |
| </div> | |
| <input type="text" id="searchAtt" class="search-input" onkeyup="filterTable('searchAtt', 'attTable')" placeholder="Поиск по сотрудникам..."> | |
| <form method="POST" action="/attendance"> | |
| <input type="hidden" name="date" value="{{ selected_date }}"> | |
| <div class="table-responsive"> | |
| <table id="attTable"> | |
| <tr> | |
| <th>Сотрудник</th> | |
| <th>Статус на {{ selected_date }}</th> | |
| </tr> | |
| {% for emp in employees %} | |
| <tr> | |
| <td style="vertical-align: middle;"><strong>{{ emp.fio }}</strong></td> | |
| <td> | |
| <div class="radio-group"> | |
| <label> | |
| <input type="radio" name="status_{{ emp.id }}" value="work" {% if attendance.get(emp.id) == 'work' %}checked{% endif %}> | |
| Рабочий день | |
| </label> | |
| <label> | |
| <input type="radio" name="status_{{ emp.id }}" value="intern" {% if attendance.get(emp.id) == 'intern' %}checked{% endif %}> | |
| Стажировка (50%) | |
| </label> | |
| <label> | |
| <input type="radio" name="status_{{ emp.id }}" value="half" {% if attendance.get(emp.id) == 'half' %}checked{% endif %}> | |
| Пол дня (50%) | |
| </label> | |
| <label> | |
| <input type="radio" name="status_{{ emp.id }}" value="off" {% if attendance.get(emp.id) == 'off' %}checked{% endif %}> | |
| Выходной | |
| </label> | |
| </div> | |
| </td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="2" style="text-align:center;">Сотрудников пока нет</td></tr> | |
| {% endfor %} | |
| </table> | |
| </div> | |
| {% if employees %} | |
| <button type="submit" class="btn">Сохранить посещаемость</button> | |
| {% endif %} | |
| </form> | |
| """ | |
| FINANCE_TEMPLATE = """ | |
| <div class="grid-2"> | |
| <div> | |
| <h2>Выдать аванс / штраф</h2> | |
| <div class="form-container"> | |
| <form method="POST" action="/add_finance"> | |
| <label>Сотрудник</label> | |
| <select name="emp_id" required> | |
| <option value="" disabled selected>Выберите сотрудника</option> | |
| {% for emp in employees %} | |
| <option value="{{ emp.id }}">{{ emp.fio }}</option> | |
| {% endfor %} | |
| </select> | |
| <label>Дата</label> | |
| <input type="date" name="date" value="{{ today }}" required> | |
| <label>Тип операции</label> | |
| <select name="type" required> | |
| <option value="advance">Аванс</option> | |
| <option value="penalty">Штраф</option> | |
| </select> | |
| <label>Сумма</label> | |
| <input type="number" name="amount" step="0.01" required autocomplete="off"> | |
| <label>Примечание</label> | |
| <input type="text" name="note" autocomplete="off"> | |
| <button type="submit" class="btn">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <h2 style="margin-top: 40px;">История операций (текущий месяц)</h2> | |
| <input type="text" id="searchFin" class="search-input" onkeyup="filterTable('searchFin', 'finTable')" placeholder="Поиск по дате, имени, типу или примечанию..."> | |
| <div class="table-responsive"> | |
| <table id="finTable"> | |
| <tr> | |
| <th>Дата</th> | |
| <th>Сотрудник</th> | |
| <th>Тип</th> | |
| <th>Сумма</th> | |
| <th>Примечание</th> | |
| <th>Действия</th> | |
| </tr> | |
| {% for adj in adjustments|sort(attribute='date', reverse=True) %} | |
| <tr> | |
| <td>{{ adj.date }}</td> | |
| <td><strong>{{ emp_dict.get(adj.emp_id, 'Удаленный сотрудник') }}</strong></td> | |
| <td> | |
| {% if adj.type == 'advance' %} | |
| <span style="color: #2196F3; font-weight: bold;">Аванс</span> | |
| {% else %} | |
| <span style="color: #F44336; font-weight: bold;">Штраф</span> | |
| {% endif %} | |
| </td> | |
| <td>{{ "%.2f"|format(adj.amount) }}</td> | |
| <td>{{ adj.note }}</td> | |
| <td> | |
| <form method="POST" action="/delete_finance/{{ adj.id }}" style="margin:0;" onsubmit="return confirm('Удалить эту запись?');"> | |
| <button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;">Удалить</button> | |
| </form> | |
| </td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="6" style="text-align:center;">Операций пока нет</td></tr> | |
| {% endfor %} | |
| </table> | |
| </div> | |
| """ | |
| REPORT_TEMPLATE = """ | |
| <h2>Отчет по ЗП и Календарь</h2> | |
| <div class="form-container" style="margin-bottom: 30px;"> | |
| <form method="GET" action="/report" class="grid-2" style="gap: 15px;"> | |
| <div> | |
| <label>Дата начала:</label> | |
| <input type="date" name="start_date" value="{{ start_date }}" required style="margin-bottom: 0;"> | |
| </div> | |
| <div> | |
| <label>Дата окончания:</label> | |
| <input type="date" name="end_date" value="{{ end_date }}" required style="margin-bottom: 0;"> | |
| </div> | |
| <div style="grid-column: 1 / -1;"> | |
| <button type="submit" class="btn">Сформировать отчет</button> | |
| </div> | |
| </form> | |
| </div> | |
| {% if report_data %} | |
| <h3 style="margin-top: 40px;">Итоговая зарплата ({{ start_date }} — {{ end_date }})</h3> | |
| <input type="text" id="searchRep" class="search-input" onkeyup="filterTable('searchRep', 'repTable'); filterTable('searchRep', 'calTable')" placeholder="Поиск по сотруднику..."> | |
| <div class="table-responsive" style="margin-bottom: 40px;"> | |
| <table id="repTable"> | |
| <tr> | |
| <th>Сотрудник</th> | |
| <th>Раб.</th> | |
| <th>0.5 дн.</th> | |
| <th>Вых.</th> | |
| <th>Начислено (база)</th> | |
| <th>Авансы</th> | |
| <th>Штрафы</th> | |
| <th style="background: #e8e8e8; color: #000;">К выдаче</th> | |
| </tr> | |
| {% for row in report_data %} | |
| <tr> | |
| <td><strong>{{ row.fio }}</strong></td> | |
| <td>{{ row.work_days }}</td> | |
| <td>{{ row.half_shift_days }}</td> | |
| <td>{{ row.off_days }}</td> | |
| <td>{{ "%.2f"|format(row.base_salary) }}</td> | |
| <td class="text-green">{{ "%.2f"|format(row.advances) }}</td> | |
| <td class="text-red">{{ "%.2f"|format(row.penalties) }}</td> | |
| <td style="background: #fafafa; font-size: 16px;"><strong>{{ "%.2f"|format(row.final_salary) }}</strong></td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="8" style="text-align:center;">Нет данных для отображения</td></tr> | |
| {% endfor %} | |
| </table> | |
| </div> | |
| <h3>Календарь посещаемости</h3> | |
| <div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; font-size: 13px; font-weight: bold;"> | |
| <div style="display: flex; align-items: center; gap: 5px;"><div style="width: 15px; height: 15px; background: #4CAF50;"></div> - Рабочий</div> | |
| <div style="display: flex; align-items: center; gap: 5px;"><div style="width: 15px; height: 15px; background: #2196F3;"></div> - Стажировка</div> | |
| <div style="display: flex; align-items: center; gap: 5px;"><div style="width: 15px; height: 15px; background: #FF9800;"></div> - Пол дня</div> | |
| <div style="display: flex; align-items: center; gap: 5px;"><div style="width: 15px; height: 15px; background: #F44336;"></div> - Выходной</div> | |
| <div style="display: flex; align-items: center; gap: 5px;"><div style="width: 15px; height: 15px; background: #f1f1f1; border: 1px solid #ddd;"></div> - Нет данных</div> | |
| </div> | |
| <div class="cal-table-wrapper"> | |
| <table class="cal-table" id="calTable"> | |
| <tr> | |
| <th>Сотрудник</th> | |
| {% for d in dates_list %} | |
| <th title="{{ d.full }}">{{ d.short }}</th> | |
| {% endfor %} | |
| </tr> | |
| {% for row in report_data %} | |
| <tr> | |
| <td>{{ row.fio }}</td> | |
| {% for cell in row.calendar %} | |
| {% if cell == 'work' %} | |
| <td class="cell-work" title="Рабочий">Р</td> | |
| {% elif cell == 'intern' %} | |
| <td class="cell-intern" title="Стажировка">С</td> | |
| {% elif cell == 'half' %} | |
| <td class="cell-half" title="Пол дня">½</td> | |
| {% elif cell == 'off' %} | |
| <td class="cell-off" title="Выходной">В</td> | |
| {% else %} | |
| <td class="cell-none">-</td> | |
| {% endif %} | |
| {% endfor %} | |
| </tr> | |
| {% endfor %} | |
| </table> | |
| </div> | |
| {% endif %} | |
| """ | |
| SETTINGS_TEMPLATE = """ | |
| <h2>Настройки системы</h2> | |
| <div class="form-container" style="max-width: 400px;"> | |
| <form method="POST" action="/settings"> | |
| <label>Начало рабочего дня (Алматы):</label> | |
| <input type="time" name="workday_start_time" value="{{ settings.get('workday_start_time', '09:00') }}" required> | |
| <button type="submit" class="btn" style="width: 100%;">Сохранить настройки</button> | |
| </form> | |
| </div> | |
| """ | |
| def index(): | |
| data = load_data() | |
| return render_template_string(BASE_TEMPLATE.replace('{% block content %}{% endblock %}', INDEX_TEMPLATE), employees=data.get('employees', [])) | |
| def add_employee(): | |
| data = load_data() | |
| try: | |
| new_emp = { | |
| 'id': str(uuid.uuid4()), | |
| 'fio': request.form['fio'].strip(), | |
| 'iin': request.form['iin'].strip(), | |
| 'phone': request.form['phone'].strip(), | |
| 'daily_salary': float(request.form['daily_salary']) | |
| } | |
| if 'employees' not in data: data['employees'] = [] | |
| data['employees'].append(new_emp) | |
| save_data(data) | |
| flash('Сотрудник успешно добавлен.') | |
| except Exception: | |
| flash('Ошибка при добавлении сотрудника. Проверьте правильность ввода данных.') | |
| return redirect(url_for('index')) | |
| def delete_employee(emp_id): | |
| data = load_data() | |
| data['employees'] = [e for e in data.get('employees', []) if e['id'] != emp_id] | |
| save_data(data) | |
| flash('Сотрудник удален.') | |
| return redirect(url_for('index')) | |
| def attendance(): | |
| data = load_data() | |
| selected_date = request.args.get('date') or request.form.get('date') or get_almaty_date() | |
| if request.method == 'POST': | |
| if 'attendance' not in data: data['attendance'] = {} | |
| if selected_date not in data['attendance']: data['attendance'][selected_date] = {} | |
| for emp in data.get('employees', []): | |
| status = request.form.get(f"status_{emp['id']}") | |
| if status in ['work', 'off', 'intern', 'half']: | |
| data['attendance'][selected_date][emp['id']] = status | |
| save_data(data) | |
| flash('Данные о посещаемости успешно сохранены.') | |
| return redirect(url_for('attendance', date=selected_date)) | |
| att_records = data.get('attendance', {}).get(selected_date, {}) | |
| return render_template_string(BASE_TEMPLATE.replace('{% block content %}{% endblock %}', ATTENDANCE_TEMPLATE), | |
| employees=data.get('employees', []), | |
| selected_date=selected_date, | |
| attendance=att_records) | |
| def finance(): | |
| data = load_data() | |
| today = get_almaty_date() | |
| current_month_prefix = today[:7] | |
| employees = data.get('employees', []) | |
| emp_dict = {e['id']: e['fio'] for e in employees} | |
| adjustments = [a for a in data.get('adjustments', []) if a['date'].startswith(current_month_prefix)] | |
| return render_template_string(BASE_TEMPLATE.replace('{% block content %}{% endblock %}', FINANCE_TEMPLATE), | |
| employees=employees, | |
| emp_dict=emp_dict, | |
| adjustments=adjustments, | |
| today=today) | |
| def add_finance(): | |
| data = load_data() | |
| try: | |
| new_adj = { | |
| 'id': str(uuid.uuid4()), | |
| 'emp_id': request.form['emp_id'], | |
| 'date': request.form['date'], | |
| 'type': request.form['type'], | |
| 'amount': float(request.form['amount']), | |
| 'note': request.form.get('note', '').strip() | |
| } | |
| if 'adjustments' not in data: data['adjustments'] = [] | |
| data['adjustments'].append(new_adj) | |
| save_data(data) | |
| flash('Операция успешно добавлена.') | |
| except Exception: | |
| flash('Ошибка при добавлении операции.') | |
| return redirect(url_for('finance')) | |
| def delete_finance(adj_id): | |
| data = load_data() | |
| data['adjustments'] = [a for a in data.get('adjustments', []) if a['id'] != adj_id] | |
| save_data(data) | |
| flash('Запись удалена.') | |
| return redirect(url_for('finance')) | |
| def report(): | |
| data = load_data() | |
| almaty_today = datetime.now(timezone(timedelta(hours=5))) | |
| start_date = request.args.get('start_date') | |
| end_date = request.args.get('end_date') | |
| if not start_date: | |
| start_date = almaty_today.replace(day=1).strftime('%Y-%m-%d') | |
| if not end_date: | |
| _, last_day = calendar.monthrange(almaty_today.year, almaty_today.month) | |
| end_date = almaty_today.replace(day=last_day).strftime('%Y-%m-%d') | |
| report_data = [] | |
| dates_list = [] | |
| try: | |
| start_dt = datetime.strptime(start_date, '%Y-%m-%d') | |
| end_dt = datetime.strptime(end_date, '%Y-%m-%d') | |
| curr = start_dt | |
| while curr <= end_dt: | |
| dates_list.append({ | |
| 'full': curr.strftime('%Y-%m-%d'), | |
| 'short': curr.strftime('%d.%m') | |
| }) | |
| curr += timedelta(days=1) | |
| employees = data.get('employees', []) | |
| attendance = data.get('attendance', {}) | |
| adjustments = data.get('adjustments', []) | |
| for emp in employees: | |
| work_days = 0 | |
| off_days = 0 | |
| intern_days = 0 | |
| half_days = 0 | |
| cal_row = [] | |
| for d in dates_list: | |
| status = attendance.get(d['full'], {}).get(emp['id'], 'none') | |
| cal_row.append(status) | |
| if status == 'work': work_days += 1 | |
| elif status == 'off': off_days += 1 | |
| elif status == 'intern': intern_days += 1 | |
| elif status == 'half': half_days += 1 | |
| half_shift_days = intern_days + half_days | |
| daily_salary = emp.get('daily_salary', 0) | |
| base_salary = (work_days * daily_salary) + (half_shift_days * (daily_salary / 2.0)) | |
| emp_adjs = [a for a in adjustments if a['emp_id'] == emp['id'] and start_date <= a['date'] <= end_date] | |
| advances = sum(a['amount'] for a in emp_adjs if a['type'] == 'advance') | |
| penalties = sum(a['amount'] for a in emp_adjs if a['type'] == 'penalty') | |
| final_salary = base_salary - advances - penalties | |
| report_data.append({ | |
| 'fio': emp['fio'], | |
| 'work_days': work_days, | |
| 'half_shift_days': half_shift_days, | |
| 'off_days': off_days, | |
| 'base_salary': base_salary, | |
| 'advances': advances, | |
| 'penalties': penalties, | |
| 'final_salary': final_salary, | |
| 'calendar': cal_row | |
| }) | |
| except Exception: | |
| flash('Некорректный формат дат.') | |
| return render_template_string(BASE_TEMPLATE.replace('{% block content %}{% endblock %}', REPORT_TEMPLATE), | |
| start_date=start_date, end_date=end_date, | |
| report_data=report_data, dates_list=dates_list) | |
| def settings(): | |
| data = load_data() | |
| if request.method == 'POST': | |
| if 'settings' not in data: data['settings'] = {} | |
| data['settings']['workday_start_time'] = request.form['workday_start_time'] | |
| save_data(data) | |
| flash('Настройки успешно сохранены.') | |
| return redirect(url_for('settings')) | |
| return render_template_string(BASE_TEMPLATE.replace('{% block content %}{% endblock %}', SETTINGS_TEMPLATE), settings=data.get('settings', {})) | |
| if __name__ == '__main__': | |
| download_db_from_hf() | |
| load_data() | |
| if HF_TOKEN_WRITE: | |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) | |
| backup_thread.start() | |
| port = int(os.environ.get('PORT', 7860)) | |
| app.run(debug=False, host='0.0.0.0', port=port) |