diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,5 @@ -from flask import Flask, render_template_string, request, redirect, url_for + +from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory import json import os import logging @@ -6,1160 +7,2149 @@ import threading import time from datetime import datetime from huggingface_hub import HfApi, hf_hub_download -from huggingface_hub.utils import RepositoryNotFoundError +from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError from werkzeug.utils import secure_filename +import uuid # Для генерации уникальных ID +from decimal import Decimal, InvalidOperation # Для точной работы с метрами/сантиметрами +# --- Настройки приложения --- app = Flask(__name__) -DATA_FILE = 'data_zzirix.json' +app.secret_key = os.urandom(24) # Необходим для flash сообщений +DATA_FILE = 'data.json' # Основной файл данных +UPLOAD_FOLDER = 'uploads' # Папка для временных файлов (если нужна) +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +# --- Настройки Hugging Face --- +# !!! ВАЖНО: Установите переменные окружения HF_TOKEN_WRITE и HF_TOKEN_READ !!! +# В реальном приложении используйте python-dotenv или системные переменные +HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE") # Замените или установите переменную окружения +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE") # Замените или установите переменную окружения +REPO_ID = os.getenv("HF_REPO_ID", "YourUsername/YourTextileRepo") # !!! ЗАМЕНИТЕ НА ВАШ РЕПОЗИТОРИЙ !!! +# Убедитесь, что репозиторий существует на Hugging Face как Dataset -# Настройки Hugging Face -REPO_ID = "Kgshop/clients" -HF_TOKEN_WRITE = os.getenv("HF_TOKEN") -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") +# --- Настройка логирования --- +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -# Ссылка на логотип -LOGO_URL = "https://huggingface.co/spaces/Kgshop/Zzirixadm/resolve/main/Picsart_25-03-20_15-38-36-600.jpg" +# --- Блокировка для безопасной работы с файлом --- +data_lock = threading.Lock() -# Настройка логирования -logging.basicConfig(level=logging.DEBUG) +# --- Вспомогательные функции для работы с данными --- def load_data(): - try: - download_db_from_hf() - with open(DATA_FILE, 'r', encoding='utf-8') as file: - data = json.load(file) - logging.info("Данные успешно загружены из JSON") - if not isinstance(data, dict) or 'products' not in data or 'categories' not in data: - return {'products': [], 'categories': [] if not isinstance(data, list) else data} - return data - except FileNotFoundError: - logging.warning("Локальный файл базы данных не найден после скачивания.") - return {'products': [], 'categories': []} - except json.JSONDecodeError: - logging.error("Ошибка: Невозможно декодировать JSON файл.") - return {'products': [], 'categories': []} - except RepositoryNotFoundError: - logging.error("Репозиторий не найден. Создание локальной базы данных.") - return {'products': [], 'categories': []} - except Exception as e: - logging.error(f"Произошла ошибка при загрузке данных: {e}") - return {'products': [], 'categories': []} + """Загружает данные из JSON файла, скачивая с Hugging Face при необходимости.""" + with data_lock: + try: + # Пытаемся скачать последнюю версию с HF + logging.info(f"Попытка скачивания {DATA_FILE} из репозитория {REPO_ID}...") + hf_hub_download( + repo_id=REPO_ID, + filename=DATA_FILE, + repo_type="dataset", + token=HF_TOKEN_READ, + local_dir=".", + local_dir_use_symlinks=False, + force_download=True # Всегда скачиваем последнюю версию + ) + logging.info("База данных успешно скачана из Hugging Face.") + except RepositoryNotFoundError: + logging.warning(f"Репозиторий {REPO_ID} не найден на Hugging Face. Проверяем локальный файл.") + except HfHubHTTPError as e: + if e.response.status_code == 404: + logging.warning(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}. Проверяем локальный файл.") + else: + logging.error(f"Ошибка HTTP при скачивании из Hugging Face: {e}") + except Exception as e: + logging.error(f"Неизвестная ошибка при скачивании из Hugging Face: {e}") + + # Читаем локальный файл (скачанный или существующий) + try: + with open(DATA_FILE, 'r', encoding='utf-8') as file: + data = json.load(file) + logging.info("Данные успешно загружены из локального JSON.") + # Проверка базовой структуры + if not isinstance(data, dict): + logging.warning("Файл данных не является словарем, инициализация пустой структурой.") + return initialize_data_structure() + # Убедимся, что все ключи существуют + default_data = initialize_data_structure() + for key in default_data.keys(): + if key not in data: + data[key] = default_data[key] + return data + except FileNotFoundError: + logging.warning(f"Локальный файл {DATA_FILE} не найден. Инициализация пустой структурой.") + return initialize_data_structure() + except json.JSONDecodeError: + logging.error(f"Ошибка декодирования JSON в файле {DATA_FILE}. Инициализация пустой структурой.") + # Можно добавить логику бэкапа поврежденного файла здесь + return initialize_data_structure() + except Exception as e: + logging.error(f"Неизвестная ошибка при загрузке локальных данных: {e}") + return initialize_data_structure() def save_data(data): - try: - with open(DATA_FILE, 'w', encoding='utf-8') as file: - json.dump(data, file, ensure_ascii=False, indent=4) - logging.info("Данные успешно сохранены в JSON") - upload_db_to_hf() - except Exception as e: - logging.error(f"Ошибка при сохранении данных: {e}") - raise + """Сохраняет данные в JSON файл и загружает на Hugging Face.""" + with data_lock: + try: + # Сначала сохраняем локально + temp_file = DATA_FILE + ".tmp" + with open(temp_file, 'w', encoding='utf-8') as file: + # Используем собственный сериализатор для Decimal + json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder) + os.replace(temp_file, DATA_FILE) # Атомарная замена файла + logging.info(f"Данные успешно сохранены в локальный файл {DATA_FILE}.") + + # Затем загружаем на Hugging Face + upload_db_to_hf() + + except Exception as e: + logging.error(f"Критическая ошибка при сохранении данных: {e}") + # Можно добавить логику отката или повторной попытки + # Временный файл может остаться, если os.replace не сработал + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except OSError as rm_err: + logging.error(f"Не удалось удалить временный файл {temp_file}: {rm_err}") + # Не вызываем upload_db_to_hf(), так как локальное сохранение могло не удасться + +def initialize_data_structure(): + """Возвращает пустую структуру данных по умолчанию.""" + return { + 'materials': [], # Закупленные материалы (ткани, фурнитура) + 'categories': [], # Список категорий материалов + 'cutting_tasks': [], # Задания на раскрой (что вырезано, сколько) + 'sewing_tasks': [], # Задания на пошив (что сшито, из чего) + 'qc_packing_items': [], # Готовые и упакованные изделия + 'defect_log': [] # Журнал брака + } def upload_db_to_hf(): + """Загружает локальный файл данных на Hugging Face.""" + if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE": + logging.warning("Токен HF_TOKEN_WRITE не установлен. Загрузка на Hugging Face пропущена.") + return try: api = HfApi() api.upload_file( path_or_fileobj=DATA_FILE, - path_in_repo=DATA_FILE, + path_in_repo=DATA_FILE, # Имя файла в репозитории repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Автоматическое резервное копирование базы данных {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) - logging.info("Резервная копия JSON базы успешно загружена на Hugging Face.") - except Exception as e: - logging.error(f"Ошибка при загрузке резервной копии: {e}") - -def download_db_from_hf(): - try: - hf_hub_download( - repo_id=REPO_ID, - filename=DATA_FILE, - repo_type="dataset", - token=HF_TOKEN_READ, - local_dir=".", - local_dir_use_symlinks=False - ) - logging.info("JSON база успешно скачана из Hugging Face.") - except RepositoryNotFoundError as e: - logging.error(f"Репозиторий не найден: {e}") - raise + logging.info(f"Резервная копия {DATA_FILE} успешно загружена на Hugging Face в репозиторий {REPO_ID}.") + except RepositoryNotFoundError: + logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face. Убедитесь, что он создан.") except Exception as e: - logging.error(f"Ошибка при скачивании JSON базы: {e}") - raise + logging.error(f"Ошибка при загрузке резервной копии на Hugging Face: {e}") def periodic_backup(): + """Периодически вызывает upload_db_to_hf.""" + logging.info("Запуск потока периодического резервного копирования.") while True: - upload_db_to_hf() - time.sleep(800) + time.sleep(1800) # Каждые 30 минут + logging.info("Запуск планового резервного копирования...") + with data_lock: # Блокируем на время чтения файла для загрузки + upload_db_to_hf() + +# --- Сериализатор для Decimal --- +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + # Сохраняем как строку для точности + return str(obj) + # Пусть базовый класс обработает остальные типы + return json.JSONEncoder.default(self, obj) + +# --- Преобразователь для Decimal в маршрутах --- +def to_decimal(value_str): + """Безопасно преобразует строку в Decimal.""" + if not value_str: + return Decimal('0.00') + try: + # Заменяем запятую на точку для универсальности ввода + return Decimal(value_str.replace(',', '.')) + except InvalidOperation: + logging.warning(f"Не удалось преобразовать '{value_str}' в Decimal. Возвращено 0.") + return Decimal('0.00') + +# --- Вспомогательные функции для поиска --- +def find_material_by_id(material_id): + """Находит материал по ID.""" + data = load_data() + for material in data.get('materials', []): + if material.get('id') == material_id: + # Преобразуем количество обратно в Decimal при чтении + material['quantity'] = to_decimal(material.get('quantity', '0')) + return material + return None + +def find_cutting_task_by_id(task_id): + """Находит задание на раскрой по ID.""" + data = load_data() + for task in data.get('cutting_tasks', []): + if task.get('id') == task_id: + # Преобразуем числовые поля обратно в Decimal/int + task['cut_items_quantity'] = int(task.get('cut_items_quantity', 0)) + task['fabric_used'] = to_decimal(task.get('fabric_used', '0')) + if 'required_fittings' in task: + for fitting in task['required_fittings']: + fitting['quantity_needed'] = int(fitting.get('quantity_needed', 0)) + return task + return None + +def find_sewing_task_by_id(task_id): + """Находит задание на пошив по ID.""" + data = load_data() + for task in data.get('sewing_tasks', []): + if task.get('id') == task_id: + # Преобразуем числовые поля обратно в Decimal/int + task['sewn_quantity'] = int(task.get('sewn_quantity', 0)) + if 'fittings_consumed' in task: + for fitting in task['fittings_consumed']: + fitting['quantity_used'] = int(fitting.get('quantity_used', 0)) + if 'defects' in task: + for defect in task['defects']: + defect['quantity'] = to_decimal(defect.get('quantity', '0')) if defect.get('type') == 'fabric' else int(defect.get('quantity', 0)) + return task + return None + +# --- Маршруты Flask --- @app.route('/') -def catalog(): +def index(): + """Главная страница, перенаправляет на админку.""" + return redirect(url_for('admin_panel')) + +# 1. Маршрут "Закуп" +@app.route('/procurement', methods=['GET', 'POST']) +def procurement(): + """Страница для добавления закупленных материалов.""" data = load_data() - products = data['products'] - categories = data['categories'] - - catalog_html = ''' - - - - - - ZZIRIX - сотовые аксессуары оптом - - - - - - -
-
- -

Каталог

- -
-
- - {% for category in categories %} - - {% endfor %} -
-
- -
-
- {% for product in products %} -
- {% if product.get('photos') and product['photos']|length > 0 %} -
- {{ product['name'] }} -
- {% endif %} -

{{ product['name'] }}

-
{{ product['price'] }} с
-

{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}

- - -
- {% endfor %} -
-
+ categories = data.get('categories', []) - - + if request.method == 'POST': + try: + materials_to_add = [] + # Обрабатываем каждую строку товара из формы + item_names = request.form.getlist('item_name[]') + item_quantities = request.form.getlist('item_quantity[]') + item_units = request.form.getlist('item_unit[]') + item_per_unit_list = request.form.getlist('item_per_unit[]') + item_types = request.form.getlist('item_type[]') + item_categories = request.form.getlist('item_category[]') + new_categories = request.form.getlist('item_new_category[]') # Поле для новой категории - - + if not item_names: + flash("Не добавлено ни одного товара.", "warning") + return redirect(url_for('procurement')) - - + for i in range(len(item_names)): + name = item_names[i].strip() + quantity_str = item_quantities[i] + unit = item_units[i] + per_unit_str = item_per_unit_list[i] + item_type = item_types[i] # 'fabric' или 'fittings' + category = item_categories[i] + new_category_name = new_categories[i].strip() - - - - - - - - - ''' - return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID) + # Можно добавить расчеты итогов, если нужно + total_packed_count = sum(item.get('quantity', 0) for item in packed_items) + total_defect_items = sum(item.get('quantity', 0) for item in defect_log) # Суммируем весь брак -@app.route('/product/') -def product_detail(index): + return render_template_string( + ADMIN_TEMPLATE, + materials=materials_dec, + cutting_tasks=cutting_tasks_dec, + sewing_tasks=sewing_tasks, # В sewing_tasks количество целочисленное + packed_items=packed_items, # В packed_items количество целочисленное + defect_log=defect_log, # Количество может быть разным + categories=categories, + total_packed_count=total_packed_count, + total_defect_items=total_defect_items + ) + +# Маршруты для управления категориями в админке +@app.route('/admin/category/add', methods=['POST']) +def add_category(): data = load_data() - products = data['products'] + categories = data.get('categories', []) + new_category = request.form.get('new_category_name', '').strip() + + if new_category and new_category not in categories: + categories.append(new_category) + data['categories'] = sorted(list(set(categories))) + save_data(data) + flash(f"Категория '{new_category}' успешно добавлена.", "success") + elif not new_category: + flash("Название категории не может быть пустым.", "warning") + else: + flash(f"Категория '{new_category}' уже существует.", "warning") + + return redirect(url_for('admin_panel')) + +@app.route('/admin/category/delete', methods=['POST']) +def delete_category(): + data = load_data() + categories = data.get('categories', []) + category_to_delete = request.form.get('category_to_delete') + + if category_to_delete and category_to_delete in categories: + categories.remove(category_to_delete) + data['categories'] = sorted(categories) + # Опционально: изменить категорию у материалов + materials = data.get('materials', []) + updated_count = 0 + for mat in materials: + if mat.get('category') == category_to_delete: + mat['category'] = 'Без категории' # Или другая категория по умолчанию + updated_count += 1 + + save_data(data) + flash(f"Категория '{category_to_delete}' удалена.", "success") + if updated_count > 0: + flash(f"{updated_count} материалов перенесены в категорию 'Без категории'.", "info") + elif not category_to_delete: + flash("Не выбрана категория для удаления.", "warning") + else: + flash(f"Категория '{category_to_delete}' не найдена.", "warning") + + return redirect(url_for('admin_panel')) + + +# Маршруты для Hugging Face (как в примере, но адаптированные) +@app.route('/backup', methods=['POST']) +def backup_hf(): + """Принудительно создает резервную копию на HF.""" + try: + # save_data уже вызывает upload_db_to_hf, но можно вызвать явно для уверенности + # load_data() # Убедимся, что работаем с актуальными данными перед сохранением + data = load_data() + save_data(data) # Сохранит локально и загрузит на HF + flash("Резервная копия успешно создана и загружена на Hugging Face.", "success") + except Exception as e: + logging.error(f"Ошибка при ручном резервном копировании: {e}") + flash(f"Ошибка при создании резервной копии: {e}", "danger") + return redirect(url_for('admin_panel')) # Или куда удобнее + +@app.route('/download', methods=['GET']) +def download_hf(): + """Принудительно скачивает базу данных с HF (перезаписывает локальную).""" + # !!! ОСТОРОЖНО: Эта операция перезапишет локальные несинхронизированные изменения !!! try: - product = products[index] - except IndexError: - return "Продукт не найден", 404 - detail_html = ''' -
-

{{ product['name'] }}

-
-
- {% if product.get('photos') %} - {% for photo in product['photos'] %} -
-
- {{ product['name'] }} + # Просто вызываем load_data, он сам скачает + load_data() + flash(f"База данных успешно скачана с Hugging Face. Локальный файл {DATA_FILE} обновлен.", "success") + except Exception as e: + logging.error(f"Ошибка при ручном скачивании базы данных: {e}") + flash(f"Ошибка при скачивании базы данных: {e}", "danger") + return redirect(url_for('admin_panel')) + + +# --- HTML Шаблоны --- + +# Базовый шаблон для структуры страниц +BASE_TEMPLATE = """ + + + + + + {{ title }} - Текстиль Учет + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+

© {{ now().year }} Текстиль Учет. Все права защищены.

+
+
+ + +{% block scripts %}{% endblock %} + + +""" + +PROCUREMENT_TEMPLATE = """ +{% extends "BASE_TEMPLATE" %} +{% block title %}Закуп материалов{% endblock %} + +{% block content %} +
+
+ Добавить закупленные материалы +
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + На ск-ко изделий +
+
+ + +
+
+ + + +
+
+
- {% endfor %} - {% else %} -
- No Image -
- {% endif %}
-
-
-
-
-

Категория: {{ product.get('category', 'Без категории') }}

-

Цена: {{ product['price'] }} с

-

Описание: {{ product['description'] }}

-

Доступные цвета: {{ product.get('colors', ['Нет цветов'])|join(', ') }}

-

Доступные модели: {{ product.get('models', ['Нет моделей'])|join(', ') }}

+ + + +
- ''' - return render_template_string(detail_html, product=product, repo_id=REPO_ID) +
+{% endblock %} -@app.route('/admin', methods=['GET', 'POST']) -def admin(): - data = load_data() - products = data['products'] - categories = data['categories'] +{% block scripts %} + +{% endblock %} +""" + +CUTTING_TEMPLATE = """ +{% extends "BASE_TEMPLATE" %} +{% block title %}Раскрой ткани{% endblock %} + +{% block content %} +
+
Регистрация раскроя
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
-

Добавление товара

- - - - - - - - - - - - - -
-
- + +
+
Необходимая фурнитура для пошива (на ВСЕ изделия):
+
+ +
+
+
-
- - -
-
- +
+ +
+
{# Место для показа доступного кол-ва #} +
+
- - - - -

Управление категориями

-
- - - - -
- -

Список категорий

-
- {% for category in categories %} -
-

{{ category }}

-
- - - -
-
- {% endfor %}
+ + +
+ + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} +""" -

Управление базой данных

-
- -
-
- -
- -

Список товаров

-
- {% for product in products %} -
-

{{ product['name'] }}

-

Категория: {{ product.get('category', 'Без категории') }}

-

Цена: {{ product['price'] }} с

-

Описание: {{ product['description'] }}

-

Цвета: {{ product.get('colors', ['Нет цветов'])|join(', ') }}

-

Модели: {{ product.get('models', ['Нет моделей'])|join(', ') }}

- {% if product.get('photos') and product['photos']|length > 0 %} -
- {% for photo in product['photos'] %} - {{ product['name'] }} +SEWING_TEMPLATE = """ +{% extends "BASE_TEMPLATE" %} +{% block title %}Пошив изделий{% endblock %} + +{% block content %} +
+
Регистрация пошива
+
+ {% if cutting_tasks %} +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
Не больше, чем было раскроено (0).
+
+
+ +
+
Регистрация брака (если есть):
+
+ +
+
+ + +
+
+ + +
+
{# Место для показа доступного кол-ва #} +
+
- {% endif %} -
- Редактировать - - - - - - - - - - - - - - -
- {% for color in product.get('colors', []) %} -
- -
- {% endfor %} -
- - -
- {% for model in product.get('models', []) %} -
- -
- {% endfor %} -
- - - -
-
- - - -
- {% endfor %}
+ + +
+ + + {% else %} + - - - - ''' - return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID) -@app.route('/backup', methods=['POST']) -def backup(): - upload_db_to_hf() - return "Резервная копия создана.", 200 + detailsDiv.style.display = 'block'; + } else { + detailsDiv.style.display = 'none'; + maxSewnSpan.textContent = '0'; + sewnInput.max = ''; // Сбрасываем максимум + } + } -@app.route('/download', methods=['GET']) -def download(): - download_db_from_hf() - return "База данных скачана.", 200 + function addDefectRow() { + const container = document.getElementById('defect-rows'); + const firstRow = container.querySelector('.dynamic-defect-row'); + if (!firstRow) return; + const newRow = firstRow.cloneNode(true); + + // Очистка + newRow.querySelectorAll('select').forEach(select => select.selectedIndex = 0); + newRow.querySelectorAll('input[type="text"]').forEach(input => input.value = ''); + newRow.querySelector('.defect-availability').textContent = ''; + + const removeBtn = newRow.querySelector('.remove-defect-row-btn'); + if(removeBtn) removeBtn.style.display = 'inline-block'; + + container.appendChild(newRow); + updateDefectRemoveButtons(); + attachDefectChangeEvent(newRow); + } + + function removeDefectRow(button) { + const row = button.closest('.dynamic-defect-row'); + row.remove(); + updateDefectRemoveButtons(); + } + + function updateDefectRemoveButtons() { + const rows = document.querySelectorAll('.dynamic-defect-row'); + rows.forEach((row, index) => { + const removeBtn = row.querySelector('.remove-defect-row-btn'); + if (removeBtn) removeBtn.style.display = 'inline-block'; + }); + } + + function handleDefectChange(selectElement) { + const row = selectElement.closest('.dynamic-defect-row'); + const availabilityDiv = row.querySelector('.defect-availability'); + const quantityInput = row.querySelector('input[name="defect_quantity[]"]'); + const selectedOption = selectElement.options[selectElement.selectedIndex]; + + if (selectedOption && selectedOption.value) { + const quantity = selectedOption.getAttribute('data-quantity'); + const unit = selectedOption.getAttribute('data-unit'); + const type = selectedOption.getAttribute('data-type'); + availabilityDiv.textContent = `Доступно: ${quantity} ${unit}`; + // Устанавливаем тип ввода в зависимости от типа материала + quantityInput.inputMode = (type === 'fabric') ? 'decimal' : 'numeric'; + quantityInput.placeholder = (type === 'fabric') ? 'e.g., 0.5' : 'Кол-во (шт)'; + } else { + availabilityDiv.textContent = ''; + quantityInput.inputMode = 'text'; + quantityInput.placeholder = 'Количество'; + } + } + + function attachDefectChangeEvent(rowElement) { + const defectSelect = rowElement.querySelector('.defect-material-select'); + if(defectSelect) { + defectSelect.addEventListener('change', function() { + handleDefectChange(this); + }); + } + } + + // Инициализация + document.addEventListener('DOMContentLoaded', () => { + showTaskDetails(); // Показать детали для выбранного по умолчанию (если есть) + updateDefectRemoveButtons(); // Обновить кнопки брака + document.querySelectorAll('.dynamic-defect-row').forEach(row => { + attachDefectChangeEvent(row); + const defectSelect = row.querySelector('.defect-material-select'); + if (defectSelect) handleDefectChange(defectSelect); + }); + }); + +{% endblock %} +""" + +QC_PACKING_TEMPLATE = """ +{% extends "BASE_TEMPLATE" %} +{% block title %}ОТК и Упаковка{% endblock %} + +{% block content %} +
+
ОТК и Упаковка готовых изделий
+
+ {% if sewing_tasks %} +
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ {% else %} + + {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} +""" + +ADMIN_TEMPLATE = """ +{% extends "BASE_TEMPLATE" %} +{% block title %}Админ-панель{% endblock %} + +{% block content %} +

Админ-панель

+

Обзор состояния производства

+ + +
+
+
+
+
Материалы
+

{{ materials|length }}

+ позиций на складе +
+
+
+
+
+
+
В раскрое
+

{{ cutting_tasks|selectattr('status', 'equalto', 'pending')|list|length }}

+ заданий ожидают +
+
+
+
+
+
+
В пошиве
+

{{ sewing_tasks|selectattr('status', 'equalto', 'pending_qc')|list|length }}

+ заданий ожидают ОТК +
+
+
+
+
+
+
Упаковано
+

{{ total_packed_count }}

+ готовых изделий +
+
+
+
{# Перенос на новую строку для мобильных #} +
+
+
Брак
+

{{ total_defect_items }}

+ единиц за все время +
+
+
+
+ + +
+
Управление категориями материалов
+
+
+
+
Добавить новую категорию
+
+ + +
+
+
+
Удалить категорию
+ {% if categories %} +
+ + +
+ {% else %} +

Нет категорий для удаления.

+ {% endif %} +
+
+
Существующие категории:
+ {% if categories %} +
    + {% for category in categories %} +
  • {{ category }}
  • + {% endfor %} +
+ {% else %} +

Категории еще не добавлены.

+ {% endif %} +
+
+ + + + + +
+ +
+
+
Список материалов на складе
+
+
+ + + + + + + + + + + + + + + {% for material in materials %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDНазваниеКатегорияТипКоличествоЕд.изм.На ед.Добавлен
{{ material.id[:8] }}...{{ material.name }}{{ material.category | default('Без категории') }}{{ 'Ткань' if material.type == 'fabric' else 'Фурнитура' }}{{ material.quantity|string|replace('.', ',') }}{{ material.unit }}{{ material.items_per_unit | default(0) }}{{ material.timestamp_added[:16] | replace('T', ' ') if material.timestamp_added else 'N/A' }}
Материалы еще не добавлены.
+
+
+
+
+ + +
+
+
Задания на раскрой
+
+
+ + + + + + + + + + + + + + + {% for task in cutting_tasks %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDТканьКол-во изделийРасход тканиФурнитураСтатусСозданоЗ��вершено
{{ task.id[:8] }}...{{ task.fabric_name }} ({{ task.fabric_id[:6] }}...){{ task.cut_items_quantity }}{{ task.fabric_used|string|replace('.', ',') }} {{ task.fabric_unit }} + {% if task.required_fittings %} +
    + {% for fitting in task.required_fittings %} +
  • {{ fitting.fitting_name }}: {{ fitting.quantity_needed }} шт.
  • + {% endfor %} +
+ {% else %} + Нет + {% endif %} +
{{ task.status|replace('_', ' ')|title }}{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }}{{ task.timestamp_completed[:16] | replace('T', ' ') if task.timestamp_completed else '-' }}
Задания на раскрой еще не создавались.
+
+
+
+
+ + +
+
+
Задания на пошив
+
+
+ + + + + + + + + + + + + + + + + {% for task in sewing_tasks %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDИзделиеКол-воТканьФурнитура исп.Брак на пошивеСтатусСозданоЗавершено (ОТК)Раскрой ID
{{ task.id[:8] }}...{{ task.product_name }}{{ task.sewn_quantity }}{{ task.fabric_name }} + {% if task.fittings_consumed %} +
    + {% for fitting in task.fittings_consumed %} +
  • {{ fitting.fitting_name }}: {{ fitting.quantity_used }} шт.
  • + {% endfor %} +
+ {% else %} + Нет + {% endif %} +
+ {% if task.defects_reported %} +
    + {% for defect in task.defects_reported %} +
  • {{ defect.material_name }}: {{ defect.quantity|string|replace('.', ',') }} {{ defect.unit }}
  • + {% endfor %} +
+ {% else %} + Нет + {% endif %} +
{{ task.status|replace('_', ' ')|title }}{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }} + {% if task.timestamp_completed %} + {{ task.timestamp_completed[:16] | replace('T', ' ') }} +
(Упак: {{ task.qc_packed_quantity | default(0) }}, Брак ОТК: {{ task.qc_defective_quantity | default(0) }}) + {% else %} + - + {% endif %} +
{{ task.cutting_task_id[:8] }}...
Задания на пошив еще не создавались.
+
+
+
+
+ + +
+
+
Готовые и упакованные изделия
+
+
+ + + + + + + + + + + + {% for item in packed_items %} + + + + + + + + {% else %} + + {% endfor %} + +
IDНазвание изделияКоличествоДата упаковкиПошив ID
{{ item.id[:8] }}...{{ item.product_name }}{{ item.quantity }}{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}{{ item.sewing_task_id[:8] }}...
Нет упакованных изделий.
+
+
+
+
+ + +
+
+
Журнал брака
+
+
+ + + + + + + + + + + + + + + + {% for defect in defect_log %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID логаМатериал/ИзделиеТипКоличествоЕд.изм./ТипЭтапПричинаДатаПошив ID (если применимо)
{{ defect.log_id[:8] }}...{{ defect.material_name }} ({{ defect.material_id[:6] }}...){{ defect.type|replace('_', ' ')|title }}{{ defect.quantity|string|replace('.', ',') }}{{ defect.unit }}{{ defect.stage|replace('_', ' ')|title }}{{ defect.reason | default('Не указана') }}{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}{{ defect.sewing_task_id[:8] }}...
Записи о браке отсутствуют.
+
+
+
+
+
+ +{% endblock %} + +{% block scripts %} +{{ super() }} {# Включаем скрипты из базового шаблона, если они там есть #} + +{% endblock %} +""" + +# Внедряем базовый шаблон в контекст Jinja +app.jinja_env.globals['BASE_TEMPLATE'] = BASE_TEMPLATE +# Добавляем datetime в контекст для использования в шаблонах (например, в футере) +from datetime import datetime +app.jinja_env.globals['now'] = datetime.utcnow if __name__ == '__main__': - backup_thread = threading.Thread(target=periodic_backup, daemon=True) - backup_thread.start() + # Запуск потока для периодического бэкапа + # Раскомментируйте, если нужен автоматический бэкап + # backup_thread = threading.Thread(target=periodic_backup, daemon=True) + # backup_thread.start() + + # Первоначальная загрузка данных при старте try: + logging.info("Первоначальная загрузка данных при старте...") load_data() except Exception as e: - logging.error(f"Не удалось загрузить базу данных: {e}") - app.run(debug=True, host='0.0.0.0', port=7860) \ No newline at end of file + logging.critical(f"Не удалось загрузить базу данных при запуске приложения: {e}") + # В зависимости от критичности, можно либо завершить работу, либо продолжить с пустой базой + + logging.info("Запуск Flask приложения...") + # Используйте host='0.0.0.0' для доступа из сети + app.run(debug=True, host='0.0.0.0', port=7860)