diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,3948 @@ + +# Импортируем необходимые библиотеки +from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory +import json +import os +import logging +import threading +import time +from datetime import datetime, timedelta, date +import pytz # Для часовых поясов +from huggingface_hub import HfApi, hf_hub_download +from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError +from werkzeug.utils import secure_filename +import uuid # Для генерации уникальных ID +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP # Для точной работы с деньгами и метрами +import functools # Для кэширования клиента +from PIL import Image # Для создания миниатюр +import io # Для работы с байтами изображений + +# --- Настройки приложения --- +app = Flask(__name__) +app.secret_key = os.urandom(24) # Необходим для flash сообщений +DATA_FILE = 'data.json' # Основной файл данных +CLIENT_DATA_FILE = 'clients.json' # Файл данных клиентов +UPLOAD_FOLDER = 'uploads' # Папка для загруженных файлов +THUMBNAIL_FOLDER = os.path.join(UPLOAD_FOLDER, 'thumbnails') # Папка для миниатюр +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(THUMBNAIL_FOLDER, exist_ok=True) # Создаем папку для миниатюр +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER +app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # Ограничение 16MB на загрузку файла + +# --- Настройки Hugging Face --- +# !!! ВАЖНО: Установите переменные окружения HF_TOKEN_WRITE и HF_TOKEN_READ !!! +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 = "Kgshop/cech" # !!! Обновленный REPO_ID !!! + +# --- Часовой пояс --- +BISHKEK_TZ = pytz.timezone('Asia/Bishkek') + +# --- Настройка логирования --- +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +# --- Блокировки для безопасной работы с файлами --- +data_lock = threading.Lock() +client_data_lock = threading.Lock() + +# --- Вспомогательные функции для работы с данными --- + +def get_current_time(): + """Возвращает текущее время в Бишкекском часовом поясе.""" + return datetime.now(BISHKEK_TZ) + +def load_data(): + """Загружает основные данные из JSON файла, скачивая с Hugging Face при необходимости.""" + with data_lock: + # Попытка скачивания основного файла данных + try: + 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(f"{DATA_FILE} успешно скачан из Hugging Face.") + except RepositoryNotFoundError: + logging.warning(f"Репозиторий {REPO_ID} не найден. Проверяем локальный {DATA_FILE}.") + except HfHubHTTPError as e: + if e.response.status_code == 404: + logging.warning(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}. Проверяем локальный файл.") + else: + logging.error(f"Ошибка HTTP при скачивании {DATA_FILE} из Hugging Face: {e}") + except Exception as e: + logging.error(f"Неизвестная ошибка при скачивании {DATA_FILE} из 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(f"{DATA_FILE} не является словарем, инициализация пустой структурой.") + return initialize_data_structure() + # Инициализация недостающих ключей верхнего уровня + default_data = initialize_data_structure() + changed = False + for key in default_data.keys(): + if key not in data: + logging.warning(f"В {DATA_FILE} отсутствует ключ '{key}'. Инициализация значением по умолчанию.") + data[key] = default_data[key] + changed = True + elif not isinstance(data[key], type(default_data[key])): + logging.warning(f"В {DATA_FILE} ключ '{key}' имеет неверный тип ({type(data[key])} вместо {type(default_data[key])}). Инициализация значением по умолчанию.") + data[key] = default_data[key] + changed = True + + # Дополнительно проверяем config + if 'config' not in data or not isinstance(data['config'], dict): + logging.warning(f"В {DATA_FILE} отсутствует или некорректен ключ 'config'. Инициализация значением по умолчанию.") + data['config'] = default_data['config'] + changed = True + else: + for config_key, default_value in default_data['config'].items(): + if config_key not in data['config']: + logging.warning(f"В {DATA_FILE}['config'] отсутствует ключ '{config_key}'. Инициализация значением по умолчанию.") + data['config'][config_key] = default_value + changed = True + elif not isinstance(data['config'][config_key], str): # В конфиге храним строки + logging.warning(f"В {DATA_FILE}['config'] ключ '{config_key}' имеет неверный тип ({type(data['config'][config_key])} вместо str). Попытка преобразования в строку.") + try: + data['config'][config_key] = str(data['config'][config_key]) + changed = True # Изменили тип + except Exception: + logging.error(f"Не удалось преобразовать значение config '{config_key}' в строку. Установка значения по умолчанию.") + data['config'][config_key] = default_value + changed = True + + # Сохраняем, если структура была изменена при загрузке + if changed: + logging.info(f"Структура файла {DATA_FILE} была обновлена при загрузке. Сохранение изменений...") + save_data(data) # Вызываем save_data здесь же, внутри lock + + return data + except FileNotFoundError: + logging.warning(f"Локальный файл {DATA_FILE} не найден. Инициализация пустой структурой.") + return initialize_data_structure() + except json.JSONDecodeError: + logging.error(f"Ошибка декодирования JSON в файле {DATA_FILE}. Инициализация пустой структурой.") + try: + bad_file_path = f"{DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad" + os.rename(DATA_FILE, bad_file_path) + logging.info(f"Поврежденный файл {DATA_FILE} переименован в {bad_file_path}") + except Exception as backup_err: + logging.error(f"Не удалось создать бэкап поврежденного файла {DATA_FILE}: {backup_err}") + return initialize_data_structure() + except Exception as e: + logging.error(f"Неизвестная ошибка при загрузке локальных основных данных: {e}", exc_info=True) + return initialize_data_structure() + +def save_data(data): + """Сохраняет основные данные в JSON файл.""" + # Эта функция вызывается из load_data или маршрутов, уже под data_lock + try: + temp_file = DATA_FILE + ".tmp" + with open(temp_file, 'w', encoding='utf-8') as file: + json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder) + os.replace(temp_file, DATA_FILE) + logging.info(f"Основные данные успешно сохранены в локальный файл {DATA_FILE}.") + except Exception as e: + logging.error(f"Критическая оши��ка при сохранении основных данных: {e}", exc_info=True) + if os.path.exists(temp_file): + try: os.remove(temp_file) + except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_file}: {rm_err}") + +def load_client_data(): + """Загружает данные клиентов из JSON файла, скачивая с Hugging Face при необходимости.""" + with client_data_lock: + try: + logging.info(f"Попытка скачивания {CLIENT_DATA_FILE} из репозитория {REPO_ID}...") + hf_hub_download( + repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, + local_dir=".", local_dir_use_symlinks=False, force_download=True, + ) + logging.info(f"{CLIENT_DATA_FILE} успешно скачан из Hugging Face.") + except RepositoryNotFoundError: logging.warning(f"Репозиторий {REPO_ID} не найден. Проверяем локальный {CLIENT_DATA_FILE}.") + except HfHubHTTPError as e: + if e.response.status_code == 404: logging.warning(f"Файл {CLIENT_DATA_FILE} не найден в репозитории {REPO_ID}. Проверяем локальный файл.") + else: logging.error(f"Ошибка HTTP при скачивании {CLIENT_DATA_FILE} из Hugging Face: {e}") + except Exception as e: logging.error(f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE} из Hugging Face: {e}") + + try: + with open(CLIENT_DATA_FILE, 'r', encoding='utf-8') as file: + clients = json.load(file) + logging.info("Данные клиентов успешно загружены из локального JSON.") + if not isinstance(clients, list): + logging.warning(f"{CLIENT_DATA_FILE} не является списком, инициализация пустым списком.") + return [] + # Проверка структуры каждого клиента + valid_clients = [] + changed = False + for client in clients: + if isinstance(client, dict) and 'id' in client and 'name' in client: + client_changed = False + # Проверка и исправление history, если необходимо + if 'history' not in client or not isinstance(client.get('history'), list): + logging.warning(f"Обнаружен некорректный формат 'history' для клиента {client.get('id')} при загрузке. Инициализировано пустым списком.") + client['history'] = [] + client_changed = True + else: + # Дополнительная проверка элементов внутри history + valid_history = [] + history_changed = False + for record in client['history']: + if isinstance(record, dict) and 'timestamp' in record: + record_changed = False + # Проверка и исправление items + if 'items' not in record or not isinstance(record.get('items'), list): + logging.warning(f"Обнаружен некорректный формат 'items' в записи истории клиента {client.get('id')}, shipment {record.get('shipment_id', 'N/A')}. Инициализировано пустым списком.") + record['items'] = [] + record_changed = True + valid_history.append(record) + if record_changed: history_changed = True + else: + logging.warning(f"Обнаружена некорректная запись в истории клиента {client.get('id')}. Пропущена: {record}") + history_changed = True # Считаем изменением, т.к. запись удалена + if history_changed: + client['history'] = valid_history + client_changed = True + + valid_clients.append(client) + if client_changed: changed = True + else: + logging.warning(f"Обнаружена некорректная запись клиента в {CLIENT_DATA_FILE}. Пропущена: {client}") + changed = True # Считаем изменением, т.к. запись удалена + + # Сохраняем, если структура была изменена при загрузке + if changed: + logging.info(f"Структура файла {CLIENT_DATA_FILE} была обновлена при загрузке. Сохранение изменений...") + save_client_data(valid_clients) # Вызываем save_client_data здесь же, внутри lock + + return valid_clients + except FileNotFoundError: logging.warning(f"Локальный файл {CLIENT_DATA_FILE} не найден. Инициализация пустым списком."); return [] + except json.JSONDecodeError: + logging.error(f"Ошибка декодирования JSON в файле {CLIENT_DATA_FILE}. Инициализация пустым списком.") + try: + bad_file_path = f"{CLIENT_DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad" + os.rename(CLIENT_DATA_FILE, bad_file_path) + logging.info(f"Поврежденный файл {CLIENT_DATA_FILE} переименован в {bad_file_path}") + except Exception as backup_err: + logging.error(f"Не удалось создать бэкап поврежденного файла {CLIENT_DATA_FILE}: {backup_err}") + return [] + except Exception as e: logging.error(f"Неизвестная ошибка при загрузке локальных данных клиентов: {e}", exc_info=True); return [] + +def save_client_data(clients): + """Сохраняет данные клиентов в JSON файл.""" + # Эта функция вызывается из load_client_data или маршрутов, уже под client_data_lock + if not isinstance(clients, list): + logging.error(f"Попытка сохранить не-список как {CLIENT_DATA_FILE}. Операция отменена.") + return + for i, client in enumerate(clients): + if not isinstance(client, dict) or 'id' not in client: + logging.error(f"Попытка сохранить некорректный объект клиента на позиции {i} в {CLIENT_DATA_FILE}. Операция отменена.") + return + if 'history' in client and not isinstance(client['history'], list): + logging.error(f"Попытка сохранить некорректный history (не список) для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.") + return + if 'history' in client and isinstance(client['history'], list): + for j, record in enumerate(client['history']): + if not isinstance(record, dict): + logging.error(f"Попытка сохранить некорректную запись history (не словарь) на позиции {j} для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.") + return + if 'items' in record and not isinstance(record['items'], list): + logging.error(f"Попытка сохранить некорректные items (не список) в записи history {j} для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.") + return + + # Сохранение + try: + temp_file = CLIENT_DATA_FILE + ".tmp" + with open(temp_file, 'w', encoding='utf-8') as file: + json.dump(clients, file, ensure_ascii=False, indent=4) # Не используем DecimalEncoder здесь + os.replace(temp_file, CLIENT_DATA_FILE) + logging.info(f"Данные клиентов успешно сохранены в локальный файл {CLIENT_DATA_FILE}.") + except Exception as e: + logging.error(f"Критическая ошибка при сохранении данных клиентов: {e}", exc_info=True) + if os.path.exists(temp_file): + try: os.remove(temp_file) + except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_file}: {rm_err}") + +def initialize_data_structure(): + """Возвращает пустую структуру основных данных по умолчанию.""" + return { + 'materials': [], 'categories': [], 'cutting_tasks': [], 'sewing_tasks': [], + 'qc_packing_items': [], 'defect_log': [], 'expenses': [], 'dordoi_shipments': [], # Добавлено + 'cloud_files': [], # Добавлено + 'config': {'salary_cutter_per_unit': '0.00', 'salary_sewer_per_unit': '0.00', + 'salary_packer_per_unit': '0.00', 'margin_per_item': '0.00'} + } + +@functools.lru_cache(maxsize=1) +def get_hf_api(): + """Возвращает инициализированный объект HfApi.""" + if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE": + logging.warning("Токен HF_TOKEN_WRITE не установлен. Загрузка на Hugging Face будет недоступна.") + return None + try: return HfApi() + except Exception as e: logging.error(f"Ошибка инициализации HfApi: {e}"); return None + +def upload_db_to_hf(filepath=DATA_FILE): + """Загружает указанный локальный файл данных на Hugging Face.""" + api = get_hf_api() + if not api: logging.warning(f"HfApi не инициализирован. Загрузка {filepath} на Hugging Face пропущена."); return + if not os.path.exists(filepath): logging.warning(f"Локальный файл {filepath} не найден. Загрузка на Hugging Face пропущена."); return + try: + filename = os.path.basename(filepath) + commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') + logging.info(f"Начало загрузки файла {filename} на Hugging Face...") + # Используем run_as_future=True для асинхронной загрузки, чтобы не блокировать основной поток + api.upload_file( + path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset", + token=HF_TOKEN_WRITE, commit_message=f"Автоматическое резервное копирование {filename} {commit_time}", + run_as_future=True + ) + logging.info(f"Загрузка файла {filename} на Hugging Face инициирована.") + except RepositoryNotFoundError: logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face.") + except Exception as e: logging.error(f"Ошибка при инициации загрузки {filepath} на Hugging Face: {e}") + +def periodic_backup(): + """Периодически вызывает upload_db_to_hf для обоих файлов.""" + logging.info("Запуск потока периодического резервного копирования.") + while True: + backup_interval = 1800 # 30 минут + logging.debug(f"Периодический бэкап спит {backup_interval} секунд...") + time.sleep(backup_interval) + logging.info("Запуск планового резервного копирования...") + try: + # Блокировка не обязательна, так как upload_db_to_hf читает существующий файл + if os.path.exists(DATA_FILE): + upload_db_to_hf(DATA_FILE) + else: + logging.warning(f"Файл {DATA_FILE} не найден для планового бэкапа.") + + if os.path.exists(CLIENT_DATA_FILE): + upload_db_to_hf(CLIENT_DATA_FILE) + else: + logging.warning(f"Файл {CLIENT_DATA_FILE} не найден для планового бэкапа.") + logging.info("Плановое резервное копирование завершено.") + except Exception as e: + logging.error(f"Ошибка во время планового резервного копирования: {e}", exc_info=True) + + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): return str(obj) + return json.JSONEncoder.default(self, obj) + +def to_decimal(value_str, default='0.00'): + """Безопасно преобразует строку в Decimal.""" + if value_str is None or value_str == '': return Decimal(default) + try: return Decimal(str(value_str).replace(',', '.')) + except InvalidOperation: logging.warning(f"Не удалось преобразовать '{value_str}' в Decimal. Возвращено {default}."); return Decimal(default) + +def parse_iso_datetime(timestamp_str): + """Преобразует строку ISO в объект datetime со знанием часового пояса.""" + if not timestamp_str: return None + try: + # Попытка парсинга напрямую + try: dt = datetime.fromisoformat(timestamp_str) + except ValueError: + # Если не получилось, пытаемся убрать миллисекунды (если они есть) + if '.' in timestamp_str: timestamp_str = timestamp_str.split('.', 1)[0] + dt = datetime.fromisoformat(timestamp_str) # Повторная попытка + + # Проверка и установка часового пояса + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + # Если зона не ��казана, считаем UTC и конвертируем в Бишкек + return pytz.utc.localize(dt).astimezone(BISHKEK_TZ) + else: + # Если зона указана, просто конвертируем в Бишкек + return dt.astimezone(BISHKEK_TZ) + except (ValueError, TypeError) as e: + logging.warning(f"Не удалось разобрать дату: '{timestamp_str}'. Ошибка: {e}") + return None + +def find_item_by_id(item_id, item_list_name): + """Обобщенная функция для поиска элемента по ID в основном списке данных.""" + data = load_data() # Загружаем свежие данные при каждом поиске + items = data.get(item_list_name, []) + if not isinstance(items, list): # Доп. проверка + logging.error(f"Ожидался список для '{item_list_name}', но получен {type(items)}. Возврат None.") + return None + + for item in items: + if not isinstance(item, dict): # Пропускаем не-словари в списке + logging.warning(f"Обнаружен не-словарь в списке '{item_list_name}': {item}. Пропущен.") + continue + + # Проверяем как 'id', так и 'log_id' (для defect_log), так и 'file_id' (для cloud_files) + current_item_id = item.get('id') or item.get('log_id') or item.get('file_id') + if current_item_id == item_id: + item_copy = item.copy() # Возвращаем копию, чтобы не изменять исходные данные + + # --- Преобразование типов --- + decimal_fields = [] + int_fields = [] + + try: # Обернем преобразования в try-except для большей устойчивости + if item_list_name == 'materials': + decimal_fields = ['quantity', 'price_per_unit'] + int_fields = ['items_per_unit'] + elif item_list_name == 'cutting_tasks': + decimal_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost'] + int_fields = ['cut_items_quantity'] + elif item_list_name == 'sewing_tasks': + decimal_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost'] + int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] + # Обработка вложенных структур + if 'fittings_consumed' in item_copy and isinstance(item_copy['fittings_consumed'], list): + for f in item_copy['fittings_consumed']: + if isinstance(f, dict): + f['quantity_used'] = int(to_decimal(f.get('quantity_used', '0'))) + f['cost'] = to_decimal(f.get('cost', '0.00')) + if 'defects_reported' in item_copy and isinstance(item_copy['defects_reported'], list): + for d in item_copy['defects_reported']: + if isinstance(d, dict): + qty_str = d.get('quantity', '0') + defect_type = d.get('type') + d['cost'] = to_decimal(d.get('cost', '0.00')) + # Преобразование quantity в зависимости от типа брака + if defect_type == 'fabric': + d['quantity'] = to_decimal(qty_str) # Оставляем Decimal для ткани + elif defect_type in ['fittings', 'finished_product']: + try: d['quantity'] = int(to_decimal(qty_str)) # Преобразуем в int + except (InvalidOperation, ValueError): d['quantity'] = 0 + else: d['quantity'] = 0 # Неизвестный тип + elif item_list_name == 'qc_packing_items': + decimal_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'] + int_fields = ['quantity'] + elif item_list_name == 'expenses': + decimal_fields = ['amount'] + elif item_list_name == 'defect_log': + decimal_fields = ['cost'] # Основное поле cost + qty_str = item_copy.get('quantity', '0') + defect_type = item_copy.get('type') + item_copy['cost_dec'] = to_decimal(item_copy.get('cost', '0.00')) + if defect_type == 'fabric': + qty_dec = to_decimal(qty_str) + item_copy['quantity_view'] = f"{qty_dec:.2f}".replace('.', ',') # Форматированное для отображения + item_copy['quantity_raw'] = qty_dec # Decimal для расчетов + elif defect_type in ['fittings', 'finished_product']: + try: + qty_int = int(to_decimal(qty_str)) + item_copy['quantity_view'] = str(qty_int) # Строка для отображения + item_copy['quantity_raw'] = qty_int # Int для расчетов + except (InvalidOperation, ValueError): + item_copy['quantity_view'] = '0'; item_copy['quantity_raw'] = 0 + else: # Неизвестный тип + item_copy['quantity_view'] = str(qty_str); item_copy['quantity_raw'] = qty_str + # Для cloud_files преобразование не требуется + + # Применяем преобразования + for field in decimal_fields: + if item_copy.get(field) is not None: + item_copy[field] = to_decimal(item_copy.get(field)) + else: + item_copy[field] = Decimal('0.00') + logging.debug(f"Поле Decimal '{field}' отсутствует в {item_list_name} ID {item_id}. Установлено '0.00'.") + for field in int_fields: + if item_copy.get(field) is not None: + item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) + else: + item_copy[field] = 0 + logging.debug(f"Поле Int '{field}' отсутствует в {item_list_name} ID {item_id}. Установлено 0.") + + except Exception as conversion_error: + logging.error(f"Ошибка преобразования типов для {item_list_name} ID {item_id}: {conversion_error}", exc_info=True) + return None # Возвращаем None при ошибке преобразования + + return item_copy + return None # Элемент не найден + +def find_client_by_id(client_id): + """Ищет клиента по ID в базе клиентов.""" + clients = load_client_data() # Загружаем свежие и проверенные данные + for client in clients: # clients уже проверен на list в load_client_data + # client уже проверен на dict в load_client_data + if client.get('id') == client_id: + client_copy = client.copy() + # Преобразуем таймстемпы истории для удобства + # history уже проверен на list и его содержимое на dict в load_client_data + if 'history' in client_copy: + for record in client_copy['history']: + # items уже проверен на list в load_client_data + record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) + return client_copy + return None + +def create_thumbnail(image_path, thumb_path, size=(100, 100)): + """Создает миниатюру для изображения.""" + try: + with Image.open(image_path) as img: + img.thumbnail(size) + # Сохраняем в JPEG для экономии места, игнорируем ошибки если формат не поддерживается + try: + img.save(thumb_path, "JPEG") + logging.info(f"Создана миниатюра: {thumb_path}") + return os.path.basename(thumb_path) # Возвращаем имя файла миниатюры + except OSError as e: + # Если JPEG не поддерживается (например, для GIF), пытаемся сохранить в PNG + try: + img.save(thumb_path, "PNG") + logging.info(f"Создана миниатюра (PNG): {thumb_path}") + return os.path.basename(thumb_path) + except Exception as png_e: + logging.error(f"Не удалось сохранить миниатюру как PNG для {image_path}: {png_e}") + return None + except Exception as e: + logging.error(f"Не удалось сохранить миниатюру как JPEG для {image_path}: {e}") + return None + + except Exception as e: + logging.error(f"Ошибка при создании миниатюры для {image_path}: {e}") + return None + +# --- Python Helper Functions for Formatting Numbers --- +def format_currency_py(value): + """Formats a Decimal or string representation as currency (Python side).""" + try: + number = to_decimal(value) + # Формат с пробелом как разделителем тысяч и запятой как десятичным разделителем + formatted_num = f"{number:,.2f}".replace(",", "TEMP_SPACE").replace(".", ",").replace("TEMP_SPACE", " ") + return formatted_num + except (InvalidOperation, TypeError, ValueError): + return "0,00" # Возвращаем строку по умолчанию + +def format_integer_py(value): + """Formats a Decimal or string representation as an integer string (Python side).""" + try: + # Преобразуем в Decimal, затем в целое с округлением + number = to_decimal(value).to_integral_value(rounding=ROUND_HALF_UP) + # Формат с пробелом как разделителем тысяч + return f"{number:,}".replace(",", " ") + except (InvalidOperation, TypeError, ValueError): + return "0" # Возвращаем строку по умолчанию + +# --- Python Helper Functions for Status Display --- +def getStatusText(statusKey): + """Возвращает текстовое представление статуса на русском.""" + statusMap = { + 'pending': 'Ожидает пошива', + 'completed': 'Завершено', + 'pending_qc': 'Ожидает ОТК', + 'packed_ready_to_ship': 'Готово к отправке', + 'shipped_client': 'Отправлено клиенту', + 'shipped_dor_doi': 'Отправлено на Дордой' + } + return statusMap.get(statusKey, statusKey) # Возвращаем ключ, если статус неизвестен + +def getStatusClass(statusKey): + """Возвращает CSS классы для стилизации статуса.""" + classMap = { + 'pending': 'status-pending text-info', + 'completed': 'status-completed text-success', + 'pending_qc': 'status-pending_qc text-warning', + 'packed_ready_to_ship': 'status-packed_ready_to_ship text-ready', # Использует .text-ready + 'shipped_client': 'status-shipped_client text-shipped-client', # Использует .text-shipped-client + 'shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi' # Использует .text-shipped-dordoi + } + return classMap.get(statusKey, '') # Возвращаем пустую строку, если статус неизвестен + +# --- Маршруты Flask --- + +@app.route('/') +def index(): + # Перенаправляем на админ-панель по умолчанию + return redirect(url_for('admin_panel')) + +# 1. Маршрут "Закуп" +# ... (Код /procurement без изменений) ... +@app.route('/procurement', methods=['GET', 'POST']) +def procurement(): + data = load_data() + categories = data.get('categories', []) + + if request.method == 'POST': + try: + materials_to_add = [] + valid_items_processed = 0 # Счетчик успешно обработанных строк + + item_names = request.form.getlist('item_name[]') + # Проверка, что хотя бы одна строка была отправлена и не пуста + if not item_names or all(not name.strip() for name in item_names): + flash("Не добавлено ни одного товара. Заполните хотя бы одну строку.", "warning") + return redirect(url_for('procurement')) + + item_quantities = request.form.getlist('item_quantity[]') + item_units = request.form.getlist('item_unit[]') + item_prices = request.form.getlist('item_price_per_unit[]') + item_per_unit = request.form.getlist('item_per_unit[]') # Количество изделий на единицу (для фурнитуры) + item_types = request.form.getlist('item_type[]') + item_categories = request.form.getlist('item_category[]') + item_new_categories = request.form.getlist('item_new_category[]') + + procurement_time = get_current_time().isoformat() + current_materials = data.get('materials', []) # Получаем текущий список материалов + + for i in range(len(item_names)): + name = item_names[i].strip() + quantity_str = item_quantities[i] + unit = item_units[i] + price_str = item_prices[i] + items_per_unit_str = item_per_unit[i] + item_type = item_types[i] + category = item_categories[i] + new_category = item_new_categories[i].strip() + + # Пропускаем пустые строки, которые могли быть добавлены динамически + if not name and not quantity_str and not price_str and not category and not new_category: + continue + + # Валидация обязательных полей для НЕпустой строки + if not name or not quantity_str or not unit or not price_str or not item_type: + flash(f"Ошибка в строке {i+1}: Необходимо заполнить название, количество, единицу измерения, цену за единицу и тип.", "danger") + continue # Пропускаем эту строку, но продолжаем обработку остальных + + # Преобразование и валидация чисел + try: + quantity = to_decimal(quantity_str) + price = to_decimal(price_str) + except InvalidOperation: + flash(f"Ошибка в строке {i+1}: Некорректный формат числа для количества или цены.", "danger") + continue + if quantity <= 0: + flash(f"Ошибка в строке {i+1}: Количество должно быть больше нуля.", "danger") + continue + if price < 0: + flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger") + continue + + # Обработка "На ед." (items_per_unit) + items_per_unit = 0 + if items_per_unit_str: + try: items_per_unit = int(to_decimal(items_per_unit_str).to_integral_value()) + except (InvalidOperation, ValueError): flash(f"Предупреждение в строке {i+1}: Некорректное значение 'На ед.', установлено 0.", "warning"); items_per_unit = 0 + if items_per_unit < 0: items_per_unit = 0 + + # Определение категории + final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории") + # Добавляем новую категорию в общий список, если её там нет + # Убедимся, что работаем со списком строк + current_valid_categories = [c for c in categories if isinstance(c, str)] + if new_category and final_category not in current_valid_categories: + current_valid_categories.append(final_category) + categories = current_valid_categories # Обновляем основной список + + # Поиск существующего материала (по названию, типу и категории) + existing_material_index = -1 + for idx, mat in enumerate(current_materials): + # Сравниваем lowercase для имени и учитываем тип и категорию + if isinstance(mat, dict) and \ + mat.get('name','').lower() == name.lower() and \ + mat.get('type') == item_type and \ + mat.get('category', 'Без категории') == final_category: + existing_material_index = idx + break + + if existing_material_index != -1: + # --- Обновляем существующий материал --- + existing_material = current_materials[existing_material_index] + # Обновляем цену (берем последнюю закупочную) + existing_material['price_per_unit'] = str(price) + # Добавляем количество + current_quantity = to_decimal(existing_material.get('quantity', '0')) + new_quantity = current_quantity + quantity + existing_material['quantity'] = str(new_quantity) + # Обновляем единицу измерения (на всякий случай) + existing_material['unit'] = unit + # Обновляем items_per_unit + existing_material['items_per_unit'] = items_per_unit + # Обновляем время последнего обновления + existing_material['timestamp_last_updated'] = procurement_time + logging.info(f"Материал '{name}' обновлен. Новое количество: {new_quantity}, Цена: {price}, Категория: {final_category}") + valid_items_processed += 1 + else: + # --- Добавляем новый материал --- + new_material = { + 'id': uuid.uuid4().hex, + 'name': name, + 'quantity': str(quantity), + 'unit': unit, + 'price_per_unit': str(price), + 'items_per_unit': items_per_unit, # Добавляем поле + 'type': item_type, + 'category': final_category, + 'timestamp_added': procurement_time, + 'timestamp_last_updated': procurement_time + } + materials_to_add.append(new_material) + logging.info(f"Новый материал '{name}' добавлен. Количество: {quantity}, Цена: {price}, Категория: {final_category}") + valid_items_processed += 1 + + # Сохраняем данные, если хотя бы одна позиция была успешно обработана + if valid_items_processed > 0 : + if materials_to_add: + data['materials'].extend(materials_to_add) # Добавляем новые + # data['materials'] уже содержит обновленные элементы, если были только обновления + + # Обновляем и сортируем список категорий (только строки) + data['categories'] = sorted(list(set(c for c in categories if isinstance(c, str))), key=str.lower) + save_data(data) + flash(f"Закуп успешно зарегистрирован! Обработано {valid_items_processed} позиций.", "success") + upload_db_to_hf(DATA_FILE) # Запускаем бэкап + else: + # Если были ошибки во всех строках или все строки были пустые/невалидные + flash("Не было добавлено или обновлено ни одной валидной позиции.", "warning") + + return redirect(url_for('procurement')) + + except Exception as e: + logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger") + return redirect(url_for('procurement')) + + # GET запрос: отображаем страницу + # Добавляем форматированные строки для отображения в шаблоне + materials_display = [] + for m in data.get('materials', []): + if isinstance(m, dict) and 'id' in m: # Доп. проверка + m_data = find_item_by_id(m['id'], 'materials') + if m_data: + # Форматирование уже есть в find_item_by_id, но оставим для ясности + if m_data.get('type') == 'fabric': + m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) + else: + m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) + m_data['price_str'] = format_currency_py(m_data.get('price_per_unit', '0.00')) + materials_display.append(m_data) + # Фильтруем категории, оставляем только строки + valid_categories = [c for c in categories if isinstance(c, str)] + html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) + return render_template_string(html, categories=valid_categories, materials_display=materials_display) + +# 2. Маршрут "Раскрой" +# ... (Код /cutting без изменений) ... +@app.route('/cutting', methods=['GET', 'POST']) +def cutting(): + data = load_data() + # Фильтруем материалы, оставляем только ткани с положительным количеством + fabrics = [] + for m in data.get('materials', []): + if isinstance(m, dict) and m.get('type') == 'fabric': + # Используем to_decimal для проверки количества + if to_decimal(m.get('quantity', '0')) > 0: + fabrics.append(m) + + config = data.get('config', {}) + + if request.method == 'POST': + try: + fabric_id = request.form.get('fabric_id') + cut_items_quantity_str = request.form.get('cut_items_quantity') + fabric_used_str = request.form.get('fabric_used') + + # Валидация входных данных + if not fabric_id or not cut_items_quantity_str or not fabric_used_str: + flash("Необходимо выбрать ткань и заполнить все поля.", "danger") + return redirect(url_for('cutting')) + + # Ищем выбранную ткань в данных + fabric_material = find_item_by_id(fabric_id, 'materials') + if not fabric_material: # find_item_by_id вернет None, если не найдено или ошибка + flash("Выбранная ткань не найдена в базе данных.", "danger") + return redirect(url_for('cutting')) + + # Преобразование и валидация чисел + try: + cut_items_quantity = int(to_decimal(cut_items_quantity_str).to_integral_value()) + if cut_items_quantity <= 0: raise ValueError("Количество должно быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное количество раскроенных изделий. Введите целое положительное число.", "danger") + return redirect(url_for('cutting')) + + try: + fabric_used = to_decimal(fabric_used_str) + if fabric_used <= 0: raise ValueError("Расход ткани должен быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное значение использованной ткани. Введите положительное число.", "danger") + return redirect(url_for('cutting')) + + # Проверка наличия достаточного количества ткани + # find_item_by_id уже вернул quantity как Decimal + available_quantity = fabric_material.get('quantity', Decimal('0.00')) + if fabric_used > available_quantity: + flash(f"Недостаточно ткани '{fabric_material['name']}'. " + f"В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, " + f"требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger") + return redirect(url_for('cutting')) + + # Расчет стоимостей (на основе текущих данных) + # find_item_by_id уже вернул price_per_unit как Decimal + price_per_unit = fabric_material.get('price_per_unit', Decimal('0.00')) + material_cost = fabric_used * price_per_unit + + salary_cutter_per_unit = to_decimal(config.get('salary_cutter_per_unit', '0.00')) + cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter_per_unit + + # Создание записи о задании на раскрой + creation_time = get_current_time().isoformat() + cutting_task = { + 'id': uuid.uuid4().hex, + 'fabric_id': fabric_id, + 'fabric_name': fabric_material['name'], # Сохраняем имя для удобства + 'fabric_unit': fabric_material['unit'], # Сохраняем единицу измерения + 'cut_items_quantity': cut_items_quantity, # int + 'fabric_used': str(fabric_used), # Сохраняем как строку + 'status': 'pending', # Начальный статус - ожидает пошива + 'timestamp_created': creation_time, + 'timestamp_completed': None, # Время завершения (когда начнется пошив) + 'material_cost': str(material_cost), # Стоимость израсходованной ткани + 'cutting_salary_cost': str(cutting_salary_cost) # Стоимость работы раскройщика + } + + # Обновление остатка ткани + new_available_quantity = available_quantity - fabric_used + material_updated = False + current_materials = data.get('materials', []) + for i, mat in enumerate(current_materials): + if isinstance(mat, dict) and mat.get('id') == fabric_id: + # Обновляем количество и время последнего изменения + current_materials[i]['quantity'] = str(new_available_quantity.quantize(Decimal('0.01'))) # Округляем до 2 знаков + current_materials[i]['timestamp_last_updated'] = creation_time + material_updated = True + break + + if not material_updated: + # Это не должно произойти, если find_item_by_id сработал, но на всякий случай + flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger") + return redirect(url_for('cutting')) + + # Добавление задания в список и сохранение данных + if 'cutting_tasks' not in data: data['cutting_tasks'] = [] + data['cutting_tasks'].append(cutting_task) + # data['materials'] уже содержит обновленный список материалов + save_data(data) + + flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('cutting')) + + except Exception as e: + logging.error(f"Ошибка при регистрации раскроя: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при регистрации раскроя: {e}", "danger") + return redirect(url_for('cutting')) + + # GET запрос: отображаем страницу + # Преобразуем данные о ткани для отображения + fabrics_display = [] + for f in fabrics: + if isinstance(f, dict) and 'id' in f: # Доп. проверка + f_copy = find_item_by_id(f['id'], 'materials') # Получаем данные с преобразованными типами + if f_copy: + f_copy['quantity_str'] = format_currency_py(f_copy.get('quantity', '0.00')) # Форматируем для отображения + fabrics_display.append(f_copy) + + html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Раскрой ткани").replace('__CONTENT__', CUTTING_CONTENT).replace('__SCRIPTS__', CUTTING_SCRIPTS) + return render_template_string(html, fabrics=fabrics_display) + +# 3. Маршрут "Пошив" +# ... (Код /sewing без изменений) ... +@app.route('/sewing', methods=['GET', 'POST']) +def sewing(): + data = load_data() + # Находим задания раскроя, ожидающие пошива + pending_cutting_tasks = [] + for t in data.get('cutting_tasks', []): + if isinstance(t, dict) and t.get('status') == 'pending': + pending_cutting_tasks.append(t) + + # Находим доступную фурнитуру + available_fittings = [] + for m in data.get('materials', []): + if isinstance(m, dict) and m.get('type') == 'fittings': + if to_decimal(m.get('quantity', '0')) > 0: + available_fittings.append(m) + + # Все материалы (для выбора брака) + all_materials = [m for m in data.get('materials', []) if isinstance(m, dict)] + config = data.get('config', {}) + + if request.method == 'POST': + try: + cutting_task_id = request.form.get('cutting_task_id') + sewn_product_name = request.form.get('sewn_product_name', '').strip() + sewn_quantity_str = request.form.get('sewn_quantity') + + # Фурнитура + fitting_ids = request.form.getlist('fitting_ids[]') + fitting_quantities = request.form.getlist('fitting_quantities[]') + + # Брак + defect_material_ids = request.form.getlist('defect_material_id[]') + defect_quantities = request.form.getlist('defect_quantity[]') + + # Валидация основных полей + if not cutting_task_id or not sewn_product_name or not sewn_quantity_str: + flash("Необходимо выбрать задание на раскрой, указать название изделия и количество сшитых.", "danger") + return redirect(url_for('sewing')) + + # Находим задание на раскрой + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') + if not cutting_task or cutting_task.get('status') != 'pending': + flash("Выбранное задание на раскрой не найдено или уже не находится в статусе 'Ожидает пошива'.", "danger") + return redirect(url_for('sewing')) + + # Валидация количества сшитых + try: + sewn_quantity = int(to_decimal(sewn_quantity_str).to_integral_value()) + # find_item_by_id уже вернул cut_items_quantity как int + cut_quantity = cutting_task.get('cut_items_quantity', 0) + if sewn_quantity <= 0: raise ValueError("Кол-во > 0") + if sewn_quantity > cut_quantity: + flash(f"Количество сшитых ({sewn_quantity}) не может превышать количество раскроенных ({cut_quantity}).", "danger") + return redirect(url_for('sewing')) + except (InvalidOperation, ValueError): + flash("Некорректное количество сшитых изделий. Введите целое положительное число.", "danger") + return redirect(url_for('sewing')) + + # --- Обработка фурнитуры --- + fittings_consumed = [] + fittings_total_cost = Decimal('0') + materials_to_update = {} # Словарь {material_id: quantity_to_deduct} для атомарного списания + sewing_time = get_current_time().isoformat() + is_valid = True + + if fitting_ids and fitting_quantities and len(fitting_ids) == len(fitting_quantities): + for i in range(len(fitting_ids)): + fitting_id = fitting_ids[i] + quantity_str = fitting_quantities[i] + + if not fitting_id or not quantity_str: continue # Пропускаем пустые строки фурнитуры + + fitting_material = find_item_by_id(fitting_id, 'materials') + if not fitting_material or fitting_material.get('type') != 'fittings': + flash(f"Ошибка: Фурнитура с ID {fitting_id} не найдена или не является фурнитурой.", "danger") + is_valid = False; break + + try: + quantity_used = int(to_decimal(quantity_str).to_integral_value()) + if quantity_used <= 0: raise ValueError("Кол-во > 0") + except (InvalidOperation, ValueError): + flash(f"Некорректное количество для фурнитуры '{fitting_material['name']}'.", "danger") + is_valid = False; break + + # Проверка доступности с учетом уже запланированного списания + # find_item_by_id вернул quantity как Decimal, преобразуем в int для фурнитуры + available_qty_int = int(fitting_material.get('quantity', Decimal('0'))) + planned_deduction_int = int(materials_to_update.get(fitting_id, Decimal('0'))) # Тоже в int + if available_qty_int < planned_deduction_int + quantity_used: + flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. " + f"В наличии: {format_integer_py(available_qty_int)}, " + f"уже запланировано списать: {format_integer_py(planned_deduction_int)}, " + f"требуется еще: {format_integer_py(quantity_used)}.", "danger") + is_valid = False; break + + # Добавляем в план списания (остаемся с Decimal для единообразия) + materials_to_update[fitting_id] = materials_to_update.get(fitting_id, Decimal('0')) + Decimal(quantity_used) + + # Считаем стоимость фурнитуры + price = fitting_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal + cost = price * Decimal(quantity_used) + fittings_total_cost += cost + + # Добавляем в список использованной фурнитуры для задачи пошива + fittings_consumed.append({ + 'fitting_id': fitting_id, + 'fitting_name': fitting_material['name'], + 'quantity_used': quantity_used, # Сохраняем int + 'cost': str(cost) # Сохраняем строку + }) + + if not is_valid: return redirect(url_for('sewing')) + + # --- Обработка брака --- + defects_reported = [] + if defect_material_ids and defect_quantities and len(defect_material_ids) == len(defect_quantities): + for i in range(len(defect_material_ids)): + material_id = defect_material_ids[i] + quantity_str = defect_quantities[i] + + if not material_id or not quantity_str: continue # Пропускаем пустые строки брака + + defect_material = find_item_by_id(material_id, 'materials') + if not defect_material: + flash(f"Предупреждение: Материал для брака с ID {material_id} не найден.", "warning") + continue # Пропускаем, но не останавливаем процесс + + # Валидация количества брака в зависимости от типа + material_type = defect_material.get('type') + quantity_deduct = Decimal('0') + quantity_log_value = 0 # Значение для записи в лог (int или Decimal) + is_fabric = material_type == 'fabric' + + try: + if is_fabric: + quantity_deduct = to_decimal(quantity_str) + if quantity_deduct <= 0: raise ValueError("Кол-во ткани > 0") + quantity_log_value = quantity_deduct # Decimal для лога + else: # fittings + quantity_int = int(to_decimal(quantity_str).to_integral_value()) + if quantity_int <= 0: raise ValueError("Кол-во фурн. > 0") + quantity_deduct = Decimal(quantity_int) + quantity_log_value = quantity_int # int для лога + except (InvalidOperation, ValueError): + flash(f"Некорректное количество брака для '{defect_material['name']}'.", "warning") + continue # Пропускаем эту запись брака + + # Проверка доступности с учетом уже запланированного + available_qty = defect_material.get('quantity', Decimal('0')) # Уже Decimal + planned_deduction = materials_to_update.get(material_id, Decimal('0')) + effective_available = available_qty - planned_deduction + + if effective_available < quantity_deduct: + available_str = format_currency_py(effective_available) if is_fabric else format_integer_py(effective_available) + deduct_str = format_currency_py(quantity_deduct) if is_fabric else format_integer_py(quantity_deduct) + flash(f"Недостаточно '{defect_material['name']}' для списания в брак ({deduct_str} {defect_material['unit']}). " + f"Доступно с учетом других списаний: {available_str} {defect_material['unit']}.", "danger") + is_valid = False; break + + # Добавляем в план списания + materials_to_update[material_id] = planned_deduction + quantity_deduct + + # Считаем стоимость брака + price = defect_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal + defect_cost = price * quantity_deduct + + # Готовим запись для лога брака + defect_entry = { + 'log_id': uuid.uuid4().hex, + 'material_id': material_id, + 'material_name': defect_material['name'], + 'quantity': str(quantity_log_value) if is_fabric else int(quantity_log_value), # Строка Decimal или int + 'unit': defect_material['unit'], + 'type': material_type, + 'stage': 'sewing', # Этап, на котором обнаружен брак + 'reason': 'Брак при пошиве', # Можно сделать поле в форме + 'cost': str(defect_cost), # Сохраняем строку + 'sewing_task_id': None, # Будет установлен после создания задачи пошива + 'timestamp': sewing_time + } + defects_reported.append(defect_entry) + + if not is_valid: return redirect(url_for('sewing')) + + # --- Списание материалов --- + current_materials = data.get('materials', []) + for material_id, quantity_to_deduct in materials_to_update.items(): + material_updated = False + for i, mat in enumerate(current_materials): + if isinstance(mat, dict) and mat.get('id') == material_id: + current_qty = to_decimal(mat.get('quantity', '0')) + new_qty = current_qty - quantity_to_deduct + # Округление в зависимости от типа + if mat.get('type') == 'fabric': + new_qty = new_qty.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + else: # fittings + new_qty = new_qty.quantize(Decimal('0'), rounding=ROUND_HALF_UP) # Округляем до целого (0 знаков) + # Убедимся, что не ушли в минус + if new_qty < 0: new_qty = Decimal('0') + + current_materials[i]['quantity'] = str(new_qty) + current_materials[i]['timestamp_last_updated'] = sewing_time + material_updated = True + break + if not material_updated: + # Эт��го не должно произойти, если проверки выше сработали + flash(f"Критическая ошибка: Не удалось списать материал с ID {material_id}.", "danger") + return redirect(url_for('sewing')) + + # --- Расчет зарплаты швеи --- + salary_sewer_per_unit = to_decimal(config.get('salary_sewer_per_unit', '0.00')) + sewing_salary_cost = Decimal(sewn_quantity) * salary_sewer_per_unit + + # --- Создание задачи пошива --- + sewing_task = { + 'id': uuid.uuid4().hex, + 'cutting_task_id': cutting_task_id, + 'product_name': sewn_product_name, + 'sewn_quantity': sewn_quantity, # int + 'fabric_id': cutting_task['fabric_id'], # Для справки + 'fabric_name': cutting_task['fabric_name'], # Для справки + 'fittings_consumed': fittings_consumed, # Список использованной фурнитуры + 'defects_reported': [], # Сюда добавим записи из defects_reported + 'status': 'pending_qc', # Отправляем на ОТК + 'timestamp_created': sewing_time, + 'timestamp_completed': None, # Время завершения (после ОТК/упаковки) + 'qc_packed_quantity': 0, # Количество, прошедшее ОТК + 'qc_defective_quantity': 0, # Количество брака на этапе ОТК + 'fittings_cost': str(fittings_total_cost), # Общая стоимость фурнитуры + 'sewing_salary_cost': str(sewing_salary_cost), # ЗП швеи + # Переносим стоимость ЗП раскройщика из задачи раскроя (уже строка Decimal) + 'cutting_salary_cost': cutting_task.get('cutting_salary_cost', '0.00') + } + + # Привязываем ID задачи пошива к записям о браке и добавляем их в задачу + for defect in defects_reported: + defect['sewing_task_id'] = sewing_task['id'] + sewing_task['defects_reported'].append(defect) + + # --- Обновление статуса задачи раскроя --- + current_cutting_tasks = data.get('cutting_tasks', []) + cutting_task_updated = False + for i, task in enumerate(current_cutting_tasks): + if isinstance(task, dict) and task.get('id') == cutting_task_id and task.get('status') == 'pending': + current_cutting_tasks[i]['status'] = 'completed' + current_cutting_tasks[i]['timestamp_completed'] = sewing_time + cutting_task_updated = True + logging.info(f"Статус задания на раскрой {cutting_task_id} изменен на 'completed'.") + break + elif isinstance(task, dict) and task.get('id') == cutting_task_id: + # Задача найдена, но статус уже не pending (маловероятно из-за проверки выше) + cutting_task_updated = True # Считаем, что обработали, раз нашли + logging.warning(f"Попытка обновить статус для уже обработанного задания раскроя {cutting_task_id}.") + break + + if not cutting_task_updated: + # Этого тоже не должно произойти + logging.error(f"Критическая ошибка: Не удалось найти и обновить статус задания на раскрой {cutting_task_id}.") + flash(f"Критическая ошибка при обновлении статуса задания раскроя {cutting_task_id}.", "danger") + # Решаем, откатывать ли транзакцию или продолжить с предупреждением + # Пока продолжим, но залогировали ошибку. + + # --- Сохранение всех изменений --- + if 'sewing_tasks' not in data: data['sewing_tasks'] = [] + data['sewing_tasks'].append(sewing_task) + + # Добавляем брак в общий лог брака, если он был + if defects_reported: + if 'defect_log' not in data: data['defect_log'] = [] + data['defect_log'].extend(sewing_task['defects_reported']) # Добавляем те же объекты + + # data['materials'] и data['cutting_tasks'] уже обновлены + save_data(data) + flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' успешно зарегистрирован. Статус: Ожидает ОТК.", "success") + if defects_reported: + flash(f"Зарегистрировано {len(defects_reported)} позиций брака.", "warning") + + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('sewing')) + + except Exception as e: + logging.error(f"Ошибка при регистрации пошива: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при регистрации пошива: {e}", "danger") + return redirect(url_for('sewing')) + + # GET запрос: отображаем страницу + # Готовим данные для шаблона + tasks_for_template = [] + for task in pending_cutting_tasks: + if isinstance(task, dict) and 'id' in task: + task_data = find_item_by_id(task['id'], 'cutting_tasks') + if task_data: + task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00')) + tasks_for_template.append(task_data) + + fittings_for_template = [] + for f in available_fittings: + if isinstance(f, dict) and 'id' in f: + f_data = find_item_by_id(f['id'], 'materials') + if f_data: + f_data['quantity_str'] = format_integer_py(f_data.get('quantity', '0')) + fittings_for_template.append(f_data) + + all_materials_for_template = [] + for m in all_materials: + if isinstance(m, dict) and 'id' in m: + m_data = find_item_by_id(m['id'], 'materials') + if m_data: + if m_data.get('type') == 'fabric': + m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) + else: + m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) + all_materials_for_template.append(m_data) + + html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Пошив изделий").replace('__CONTENT__', SEWING_CONTENT).replace('__SCRIPTS__', SEWING_SCRIPTS) + return render_template_string(html, cutting_tasks=tasks_for_template, fittings=fittings_for_template, all_materials=all_materials_for_template) + +# 4. Маршрут "ОТК и Упаковка" +@app.route('/qc_packing', methods=['GET', 'POST']) +def qc_packing(): + data = load_data() + # Находим задания пошива, ожидающие ОТК + pending_qc_tasks = [] + for t in data.get('sewing_tasks', []): + if isinstance(t, dict) and t.get('status') == 'pending_qc': + pending_qc_tasks.append(t) + + config = data.get('config', {}) + + if request.method == 'POST': + try: + sewing_task_id = request.form.get('sewing_task_id') + quantity_packed_str = request.form.get('quantity_packed') + quantity_defective_str = request.form.get('quantity_defective', '0') # По умолчанию 0 + defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() + + # Валидация выбора задания + if not sewing_task_id: + flash("Необходимо выбрать задание на пошив.", "danger") + return redirect(url_for('qc_packing')) + + # Находим задание пошива + sewing_task = find_item_by_id(sewing_task_id, 'sewing_tasks') + if not sewing_task or sewing_task.get('status') != 'pending_qc': + flash("Выбранное задание на пошив не найдено или уже не ожидает ОТК.", "danger") + return redirect(url_for('qc_packing')) + + # Валидация количества + try: + quantity_packed = int(to_decimal(quantity_packed_str).to_integral_value()) if quantity_packed_str else 0 + quantity_defective = int(to_decimal(quantity_defective_str).to_integral_value()) if quantity_defective_str else 0 + + if quantity_packed < 0 or quantity_defective < 0: + raise ValueError("Количество не может быть отрицательным") + + total_processed_now = quantity_packed + quantity_defective + if total_processed_now <= 0: + flash("Необходимо указать количество упакованных или бракованных изделий (сумма должна быть > 0).", "warning") + return redirect(url_for('qc_packing')) + + # Рассчитываем, сколько осталось обработать по этому заданию + # find_item_by_id уже вернул int для этих полей + total_sewn = sewing_task.get('sewn_quantity', 0) + already_packed = sewing_task.get('qc_packed_quantity', 0) + already_defective = sewing_task.get('qc_defective_quantity', 0) + remaining_to_process = total_sewn - (already_packed + already_defective) + + if total_processed_now > remaining_to_process: + flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed_now}, " + f"что превышает остаток изделий для обработки ({remaining_to_process}).", "danger") + return redirect(url_for('qc_packing')) + + except (InvalidOperation, ValueError) as e: + flash(f"Некорректное количество упакованных или бракованных изделий: {e}", "danger") + return redirect(url_for('qc_packing')) + + qc_time = get_current_time().isoformat() + new_packed_item_entry = None # Запись для qc_packing_items + new_defect_log_entry = None # Запись для defect_log + + # --- Обработка упакованных (прошедших ОТК) --- + if quantity_packed > 0: + # Получаем связанные данные для расчета себестоимости + cutting_task_id = sewing_task.get('cutting_task_id') + # Используем find_item_by_id, который вернет данные с Decimal/int + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') + + # Если задание на раскрой не найдено, используем нули + if not cutting_task: + logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости для пошива {sewing_task_id}.") + cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} + + # Получаем стоимости из задач (уже в Decimal) + fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) + cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) + fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) + sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) + + # Получаем количество из задач для расчета на единицу (уже int) + cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 # Избегаем деления на ноль + sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 # Избегаем деления на ноль + + # Расчет себестоимости на 1 изделие + fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) + fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + + cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) + sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) + packing_salary_per_item = to_decimal(config.get('salary_packer_per_unit', '0.00')) + salary_cost_per_item = cutting_salary_per_item + sewing_salary_per_item + packing_salary_per_item + + total_cost_per_item = material_cost_per_item + salary_cost_per_item + + # Расчет цены продажи на 1 изделие + margin_per_item = to_decimal(config.get('margin_per_item', '0.00')) + final_price_per_item = total_cost_per_item + margin_per_item + + # Расчет общих сумм для текущей пачки упакованных + packed_batch_material_cost = material_cost_per_item * Decimal(quantity_packed) + packed_batch_salary_cost = salary_cost_per_item * Decimal(quantity_packed) + packed_batch_total_cost = total_cost_per_item * Decimal(quantity_packed) + packed_batch_margin = margin_per_item * Decimal(quantity_packed) + packed_batch_final_price = final_price_per_item * Decimal(quantity_packed) + + # Создаем запись об упакованном товаре + new_packed_item_entry = { + 'id': uuid.uuid4().hex, + 'sewing_task_id': sewing_task_id, + 'product_name': sewing_task['product_name'], + 'quantity': quantity_packed, # int + 'timestamp_packed': qc_time, + 'packed_material_cost': str(packed_batch_material_cost), + 'packed_salary_cost': str(packed_batch_salary_cost), + 'packed_total_cost': str(packed_batch_total_cost), # Себестоимость партии + 'packed_margin': str(packed_batch_margin), # Маржа партии + 'packed_final_price': str(packed_batch_final_price), # Цена продажи партии + 'status': 'packed_ready_to_ship', # Готово к отправке + 'shipment_details': None # Детали отправки будут добавлены позже + } + + if 'qc_packing_items' not in data: data['qc_packing_items'] = [] + data['qc_packing_items'].append(new_packed_item_entry) + + # --- Обработка брака на этапе ОТК --- + if quantity_defective > 0: + # Рассчитываем себестоимость бракованного изделия (без ЗП упаковщика и без маржи) + cutting_task_id = sewing_task.get('cutting_task_id') + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') + if not cutting_task: + logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости брака ОТК для {sewing_task_id}.") + cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} + + # Стоимости и кол-во уже в нужных типах из find_item_by_id + fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) + cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) + fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) + sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) + cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 + sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 + + fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) + fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + + cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) + sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) + salary_cost_per_defective_item = cutting_salary_per_item + sewing_salary_per_item + + # Себестоимость 1 бракованного изделия + cost_per_defective_item = material_cost_per_item + salary_cost_per_defective_item + # Общая стоимость списания брака + total_defect_cost = cost_per_defective_item * Decimal(quantity_defective) + + # Создаем запись для лога брака + new_defect_log_entry = { + 'log_id': uuid.uuid4().hex, + 'material_id': None, # Брак не конкретного материала, а готового изделия + 'material_name': f"{sewing_task['product_name']} (готовое изделие)", + 'quantity': quantity_defective, # int + 'unit': 'шт', + 'type': 'finished_product', # Тип брака - готовое изделие + 'stage': 'qc_packing', # Этап обнаружения + 'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке', + 'cost': str(total_defect_cost), # Общая стоимость списания + 'sewing_task_id': sewing_task_id, # Ссылка на задачу пошива + 'timestamp': qc_time + } + + if 'defect_log' not in data: data['defect_log'] = [] + data['defect_log'].append(new_defect_log_entry) + logging.info(f"Зарегистрирован брак ОТК: {quantity_defective} ед. '{sewing_task['product_name']}' (Общая стоимость: {format_currency_py(total_defect_cost)})") + + + # --- Обновление задачи пошива --- + sewing_task_updated = False + current_sewing_tasks = data.get('sewing_tasks', []) + for i, task in enumerate(current_sewing_tasks): + if isinstance(task, dict) and task.get('id') == sewing_task_id: + # Увеличиваем счетчики обработанных + current_sewing_tasks[i]['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) + quantity_packed + current_sewing_tasks[i]['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) + quantity_defective + + # Проверяем, завершена ли обработка всего задания + total_processed_for_task = current_sewing_tasks[i]['qc_packed_quantity'] + current_sewing_tasks[i]['qc_defective_quantity'] + if total_processed_for_task >= int(task.get('sewn_quantity', 0)): + # Если все обработано, меняем статус и ставим время завершения + current_sewing_tasks[i]['status'] = 'completed' + current_sewing_tasks[i]['timestamp_completed'] = qc_time + logging.info(f"Задание на пошив {sewing_task_id} полностью обработано и завершено.") + else: + # Если обработано частично, статус остается pending_qc + current_sewing_tasks[i]['status'] = 'pending_qc' # Явно оставляем статус + logging.info(f"Задание на пошив {sewing_task_id} обработано частично. Осталось: {remaining_to_process - total_processed_now}") + + sewing_task_updated = True + break + + if not sewing_task_updated: + logging.error(f"Критическая ошибка: Не удалось найти и обновить задание на пошив {sewing_task_id}.") + # Возможно, стоит откатить изменения или выдать более серьезное предупреждение + flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger") + + # --- Сохранение данных --- + # data['sewing_tasks'], data['qc_packing_items'], data['defect_log'] уже обновлены + save_data(data) + + flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. " + if new_packed_item_entry: + flash_message += f"Статус упакованных: Готово к отправке." + elif new_defect_log_entry: + flash_message += f"Брак зарегистрирован." + + flash(flash_message, "success") + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('qc_packing')) + + except Exception as e: + logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger") + return redirect(url_for('qc_packing')) + + # GET запрос: отображаем страницу + # Готовим список задач для шаблона, рассчитывая остаток + tasks_for_template = [] + for task in pending_qc_tasks: + if isinstance(task, dict) and 'id' in task: + task_data = find_item_by_id(task['id'], 'sewing_tasks') + if task_data: + total_sewn = task_data.get('sewn_quantity', 0) + already_processed = task_data.get('qc_packed_quantity', 0) + task_data.get('qc_defective_quantity', 0) + remaining = total_sewn - already_processed + if remaining > 0: # Показываем только те, где еще есть что обрабатывать + task_data['remaining_quantity'] = remaining + tasks_for_template.append(task_data) + + html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS) + return render_template_string(html, sewing_tasks=tasks_for_template) + +# 5. Маршрут "База клиентов" +@app.route('/clients', methods=['GET', 'POST']) +def clients_panel(): + if request.method == 'POST': + # Добавление нового клиента + name = request.form.get('client_name','').strip() + phone = request.form.get('client_phone','').strip() + address = request.form.get('client_address','').strip() + + if not name or not phone: + flash("Имя/Название организации и номер телефона обязательны.", "danger") + return redirect(url_for('clients_panel')) + + clients = load_client_data() # Загружает проверенные данные + # Проверка на дубликат по номеру телефона (очищенному от нецифровых символов) + normalized_phone = ''.join(filter(str.isdigit, phone)) + if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone for c in clients): + flash(f"Клиент с похожим номером телефона ({phone}) уже существует в базе.", "warning") + return redirect(url_for('clients_panel')) + + new_client = { + 'id': uuid.uuid4().hex, + 'name': name, + 'phone': phone, + 'address': address if address else None, # Сохраняем None, если адрес пуст + 'history': [] # Инициализируем пустую историю как список + } + clients.append(new_client) + save_client_data(clients) # Сохраняет проверенные данные + flash(f"Клиент '{name}' успешно добавлен.", "success") + upload_db_to_hf(CLIENT_DATA_FILE) # Бэкап данных клиентов + return redirect(url_for('clients_panel')) + + # --- GET запрос: отображение списка клиентов --- + try: + clients_data = load_client_data() # Загружаем проверенные данные + clients_data.sort(key=lambda x: x.get('name','').lower()) # Сортировка по имени + + # Подготовка данных для шаблона: обработка истории + # load_client_data УЖЕ гарантирует, что history и items являются списками + for client in clients_data: + # Сортировка истории (теперь безопасно) и парсинг дат + if 'history' in client: # Проверка типа уже не нужна, т.к. load_client_data ее сделал + # Сортируем исходный список (не создаем копию для сортировки тут) + client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True) + # Добавляем datetime объекты для удобного отображения в шаблоне + for record in client['history']: # record - точно словарь + record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) + # Проверка record['items'] уже не нужна + + html = BASE_TEMPLATE.replace('__TITLE__', "База клиентов").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS) + # Передаем обработанные данные в шаблон + return render_template_string(html, clients=clients_data) + + except Exception as e: + logging.error(f"Неожиданная ошибка в GET /clients: {e}", exc_info=True) + flash("Произошла ошибка при отображении страницы клиентов.", "danger") + # Можно перенаправить на главную или показать пустую страницу + return redirect(url_for('admin_panel')) + +# 6. Маршрут "Админ-панель" +@app.route('/admin') +def admin_panel(): + data = load_data() + clients_data = load_client_data() # Загружаем проверенных клиентов + config = data.get('config', {}) + + # Получаем актуальные данные с помощью find_item_by_id для всех списков + all_materials = [m for m_id in [m.get('id') for m in data.get('materials', []) if isinstance(m, dict)] if (m := find_item_by_id(m_id, 'materials')) is not None] + all_cutting_tasks = [t for t_id in [t.get('id') for t in data.get('cutting_tasks', []) if isinstance(t, dict)] if (t := find_item_by_id(t_id, 'cutting_tasks')) is not None] + all_sewing_tasks = [s for s_id in [s.get('id') for s in data.get('sewing_tasks', []) if isinstance(s, dict)] if (s := find_item_by_id(s_id, 'sewing_tasks')) is not None] + all_packed_items = [p for p_id in [p.get('id') for p in data.get('qc_packing_items', []) if isinstance(p, dict)] if (p := find_item_by_id(p_id, 'qc_packing_items')) is not None] + all_defect_log = [d for d_id in [d.get('log_id') for d in data.get('defect_log', []) if isinstance(d, dict)] if (d := find_item_by_id(d_id, 'defect_log')) is not None] + all_expenses = [e for e_id in [e.get('id') for e in data.get('expenses', []) if isinstance(e, dict)] if (e := find_item_by_id(e_id, 'expenses')) is not None] + # Добавлено: Получаем историю отправок на Дордой + dordoi_shipments = data.get('dordoi_shipments', []) + for ship in dordoi_shipments: # Парсим даты + ship['timestamp_dt'] = parse_iso_datetime(ship.get('timestamp')) + dordoi_shipments.sort(key=lambda x: x.get('timestamp',''), reverse=True) # Сортируем + + categories = data.get('categories', []) + + # --- Расчет сводных данных --- + items_ready_to_ship = [item for item in all_packed_items if item.get('status') == 'packed_ready_to_ship'] + items_ready_ship_count = len(items_ready_to_ship) + items_ready_ship_qty = sum(item.get('quantity', 0) for item in items_ready_to_ship) + + materials_count = len(all_materials) + pending_cutting_count = len([task for task in all_cutting_tasks if task.get('status') == 'pending']) + pending_qc_tasks = [task for task in all_sewing_tasks if task.get('status') == 'pending_qc'] + pending_qc_count = len(pending_qc_tasks) + pending_qc_quantity = sum(task.get('sewn_quantity', 0) for task in pending_qc_tasks) + total_packed_count = sum(item.get('quantity', 0) for item in all_packed_items) + total_defect_fabric_m = sum(d.get('quantity_raw', Decimal('0')) for d in all_defect_log if d.get('type') == 'fabric') + total_defect_fittings_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'fittings') + total_defect_finished_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'finished_product') + total_defect_cost = sum(d.get('cost_dec', Decimal('0')) for d in all_defect_log) + + config_decimal = {k: to_decimal(v) for k, v in config.items()} + + # Передача данных в шаблон + html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', ADMIN_SCRIPTS) + return render_template_string(html, + materials=all_materials, + cutting_tasks=all_cutting_tasks, + sewing_tasks=all_sewing_tasks, + packed_items=all_packed_items, + items_ready_to_ship=items_ready_to_ship, + clients=sorted(clients_data, key=lambda x: x.get('name','').lower()), + defect_log=all_defect_log, + expenses=all_expenses, + dordoi_shipments=dordoi_shipments, # Добавлено + categories=categories, + config=config_decimal, + materials_count=materials_count, + pending_cutting_count=pending_cutting_count, + pending_qc_count=pending_qc_count, + pending_qc_quantity=pending_qc_quantity, + total_packed_count=total_packed_count, + items_ready_ship_count=items_ready_ship_count, + items_ready_ship_qty=items_ready_ship_qty, + total_defect_fabric_m=format_currency_py(total_defect_fabric_m), + total_defect_fittings_pcs=format_integer_py(total_defect_fittings_pcs), + total_defect_finished_pcs=format_integer_py(total_defect_finished_pcs), + total_defect_cost=format_currency_py(total_defect_cost) + ) + +# 7. Маршрут для выполнения отправки - ИЗМЕНЕНО: Добавлено сохранение истории Дордоя +@app.route('/dispatch_item', methods=['POST']) +def dispatch_item(): + item_id = request.form.get('item_id') + destination_type = request.form.get('destination_type') + client_id = request.form.get('client_id') # Может быть None + + if not item_id or not destination_type: + flash("Ошибка: Не указан ID товара или тип назначения.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + data = load_data() + clients = load_client_data() + + packed_item_ref = None + item_found = False + packed_items_list = data.get('qc_packing_items', []) + + for item in packed_items_list: + if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': + packed_item_ref = item + item_found = True + break + + if not item_found: + flash(f"Ошибка: Товар с ID {item_id}, готовый к отправке, не найден.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + dispatch_time_iso = get_current_time().isoformat() + shipment_details = { + 'type': destination_type, + 'timestamp': dispatch_time_iso + } + client_data_changed = False + main_data_changed = False # Флаг для сохранения data.json + + if destination_type == 'client': + if not client_id: + flash("Ошибка: Не выбран клиент для отправки.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + client_object_to_update = None + client_found = False + for cl in clients: + if cl.get('id') == client_id: + client_object_to_update = cl + client_name = cl.get('name', 'Имя не найдено') + client_found = True + break + + if not client_found: + flash(f"Ошибка: Клиент с ID {client_id} не найден в базе.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + packed_item_ref['status'] = 'shipped_client' + shipment_details['client_id'] = client_id + shipment_details['client_name'] = client_name + packed_item_ref['shipment_details'] = shipment_details + main_data_changed = True + + history_entry = { + 'shipment_id': uuid.uuid4().hex, + 'timestamp': dispatch_time_iso, + 'items': [{'product_name': packed_item_ref.get('product_name', 'N/A'), + 'quantity': packed_item_ref.get('quantity', 0)}] if packed_item_ref else [], + 'packed_item_id': item_id + } + client_object_to_update['history'].append(history_entry) + client_data_changed = True + logging.info(f"Attempting to save client data after adding history for {client_id}...") + destination_display_text = f"клиенту '{client_name}'" + + elif destination_type == 'dor_doi_point': + packed_item_ref['status'] = 'shipped_dor_doi' + shipment_details['destination'] = 'Торговая точка Дордой' + packed_item_ref['shipment_details'] = shipment_details + main_data_changed = True + + # Добавляем запись в историю Дордоя + dordoi_entry = { + 'shipment_id': uuid.uuid4().hex, + 'timestamp': dispatch_time_iso, + 'items': [{'product_name': packed_item_ref.get('product_name', 'N/A'), + 'quantity': packed_item_ref.get('quantity', 0)}] if packed_item_ref else [], + 'packed_item_id': item_id + } + if 'dordoi_shipments' not in data or not isinstance(data['dordoi_shipments'], list): + data['dordoi_shipments'] = [] + data['dordoi_shipments'].append(dordoi_entry) + logging.info(f"Товар {item_id} ({packed_item_ref.get('product_name', 'N/A')}) помечен как 'shipped_dor_doi'. Запись добавлена в dordoi_shipments.") + destination_display_text = "на Торговую точку Дордой" + + else: + flash("Ошибка: Неверный тип назначения.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + # Сохраняем изменения + if main_data_changed: + save_data(data) + upload_db_to_hf(DATA_FILE) + logging.info(f"Основные данные сохранены после отправки товара {item_id}.") + + if client_data_changed: + save_client_data(clients) + upload_db_to_hf(CLIENT_DATA_FILE) + logging.info(f"Данные клиента {client_id} сохранены.") + + flash(f"Товар '{packed_item_ref.get('product_name', 'N/A')}' ({packed_item_ref.get('quantity', 0)} шт.) успешно отправлен {destination_display_text}.", "success") + return redirect(url_for('admin_panel') + '#dispatch-content') + +# --- Остальные маршруты админ-панели --- +# ... (Коды /admin/config/update, /admin/expense/add, /admin/category/add, /admin/category/delete, /backup, /download без изменений) ... +@app.route('/admin/config/update', methods=['POST']) +def update_config(): + data = load_data() + config = data.get('config', {}) # Получаем текущую конфигурацию или пустой словарь + try: + # Получаем значения из формы, преобразуем в Decimal, затем в строку + config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter'))) + config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer'))) + config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer'))) + config['margin_per_item'] = str(to_decimal(request.form.get('margin'))) + + data['config'] = config # Обновляем конфиг в основных данных + save_data(data) + flash("Настройки зарплат и маржи успешно сохранены.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + except InvalidOperation: + flash("Ошибка: Введено некорректное числовое значение.", "danger") + except Exception as e: + logging.error(f"Ошибка при обновлении конфигурации: {e}", exc_info=True) + flash(f"Произошла ошибка при сохранении настроек: {e}", "danger") + # Возвращаемся на админ-панель (вкладка настроек не указана, будет по умолчанию) + return redirect(url_for('admin_panel')) + +@app.route('/admin/expense/add', methods=['POST']) +def add_expense(): + data = load_data() + description = request.form.get('expense_description','').strip() + amount_str = request.form.get('expense_amount') + + if not description or not amount_str: + flash("Необходимо заполнить описание и сумму расхода.", "warning") + return redirect(url_for('admin_panel') + '#expenses-report-content') # Возвращаемся на админку, вкладка расходов + + try: + amount = to_decimal(amount_str) + if amount <= 0: raise ValueError("Сумма должна быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное значение суммы расхода. Введите положительное число.", "warning") + return redirect(url_for('admin_panel') + '#expenses-report-content') + + if 'expenses' not in data or not isinstance(data['expenses'], list): + data['expenses'] = [] # Инициализируем, если отсутствует или не список + + new_expense = { + 'id': uuid.uuid4().hex, + 'description': description, + 'amount': str(amount), # Сохраняем как строку + 'timestamp': get_current_time().isoformat() + } + data['expenses'].append(new_expense) + save_data(data) + flash(f"Расход '{description}' на сумму {format_currency_py(amount)} сом успешно добавлен.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('admin_panel') + '#expenses-report-content') # Переход на вкладку с расходами + +@app.route('/admin/category/add', methods=['POST']) +def add_category(): + data = load_data() + categories = data.get('categories', []) + new_category_name = request.form.get('new_category_name','').strip() + + if not new_category_name: + flash("Название категории не может быть пустым.", "warning") + return redirect(url_for('admin_panel')) # Возврат на админку + + # Фильтруем существующие категории, оставляем только строки + current_valid_categories = [c for c in categories if isinstance(c, str)] + + # Проверка на существование (без учета регистра) + if new_category_name.lower() not in [c.lower() for c in current_valid_categories]: + current_valid_categories.append(new_category_name) + # Обновляем список категорий в данных и сортируем + data['categories'] = sorted(list(set(current_valid_categories)), key=str.lower) + save_data(data) + flash(f"Категория '{new_category_name}' успешно добавлена.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + else: + flash(f"Категория '{new_category_name}' уже существует.", "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 not category_to_delete: + flash("Не выбрана категория для удаления.", "warning") + return redirect(url_for('admin_panel')) + + if category_to_delete == 'Без категории': + flash("Нельзя удалить системную категорию 'Без категории'.", "danger") + return redirect(url_for('admin_panel')) + + # Находим точное имя категории (с учетом регистра) для удаления + original_category_name = None + category_found = False + current_valid_categories = [c for c in categories if isinstance(c, str)] # Работаем только со строками + for cat in current_valid_categories: + if cat.lower() == category_to_delete.lower(): + original_category_name = cat + category_found = True + break + + if category_found and original_category_name: + current_valid_categories.remove(original_category_name) # Удаляем из списка строк + data['categories'] = sorted(current_valid_categories, key=str.lower) # Сохраняем отсортированный список строк + + # Обновляем материалы, которые принадлежали этой категории + materials_updated_count = 0 + current_materials = data.get('materials', []) + update_time = get_current_time().isoformat() + for mat in current_materials: + if isinstance(mat, dict) and mat.get('category', 'Без категории') == original_category_name: + mat['category'] = 'Без категории' # Переносим в базовую категорию + mat['timestamp_last_updated'] = update_time + materials_updated_count += 1 + + # data['materials'] уже обновлен, если были изменения + save_data(data) + flash(f"Категория '{original_category_name}' успешно удалена.", "success") + if materials_updated_count > 0: + flash(f"{materials_updated_count} материалов были перенесены в категорию 'Без категории'.", "info") + upload_db_to_hf(DATA_FILE) # Бэкап + else: + flash(f"Категория '{category_to_delete}' не найдена.", "warning") + + return redirect(url_for('admin_panel')) + +@app.route('/backup', methods=['POST']) +def backup_hf(): + """Инициирует ручное резервное копирование обоих файлов на Hugging Face.""" + files_uploaded_count = 0 + try: + logging.info("Запуск ручного резервного копирования на Hugging Face...") + # Бэкап основного файла данных + with data_lock: # Используем блокировку на всякий случай + if os.path.exists(DATA_FILE): + upload_db_to_hf(DATA_FILE) + files_uploaded_count += 1 + else: + flash(f"Локальный файл '{DATA_FILE}' не найден для бэкапа.", "warning") + + # Бэкап файла клиентов + with client_data_lock: # Используем блокировку + if os.path.exists(CLIENT_DATA_FILE): + upload_db_to_hf(CLIENT_DATA_FILE) + files_uploaded_count += 1 + else: + flash(f"Локальный файл '{CLIENT_DATA_FILE}' не найден для бэкапа.", "warning") + + if files_uploaded_count > 0: + flash(f"Резервное копирование {files_uploaded_count} файлов на Hugging Face инициировано.", "success") + else: + flash("Не найдено локальных файлов для инициации резервного копирования.", "warning") + + except Exception as e: + logging.error(f"Ошибка во время ручного резервного копирования: {e}", exc_info=True) + flash(f"Произошла ошибка во время резервного копирования: {e}", "danger") + + return redirect(url_for('admin_panel')) + +@app.route('/download', methods=['GET']) +def download_hf(): + """Скачивает оба файла данных с Hugging Face, перезаписывая локальные.""" + downloaded_files = [] + errors = [] + + logging.info("Запуск скачивания данных с Hugging Face...") + # Скачивание основного файла данных + try: + logging.info(f"Попытка скачивания {DATA_FILE}...") + 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) + downloaded_files.append(DATA_FILE) + logging.info(f"{DATA_FILE} успешно скачан.") + except RepositoryNotFoundError: + msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." + logging.error(msg) + errors.append(msg) + except HfHubHTTPError as e: + if e.response.status_code == 404: + msg = f"Файл '{DATA_FILE}' не найден в репозитории '{REPO_ID}'." + logging.warning(msg) + errors.append(msg) + else: + msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {DATA_FILE}: {e}" + logging.error(msg) + errors.append(msg) + except Exception as e: + msg = f"Неизвестная ошибка при скачивании {DATA_FILE}: {e}" + logging.error(msg, exc_info=True) + errors.append(msg) + + # Скачивание файла клиентов (даже если первый файл не скачался) + try: + logging.info(f"Попытка скачивания {CLIENT_DATA_FILE}...") + hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, + local_dir=".", local_dir_use_symlinks=False, force_download=True) + downloaded_files.append(CLIENT_DATA_FILE) + logging.info(f"{CLIENT_DATA_FILE} успешно скачан.") + except RepositoryNotFoundError: + # Ошибка репозитория уже должна была быть залогирована выше + if not any(f"Репозиторий '{REPO_ID}' не найден" in err for err in errors): + msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." + logging.error(msg) + errors.append(msg) + except HfHubHTTPError as e: + if e.response.status_code == 404: + msg = f"Файл '{CLIENT_DATA_FILE}' не найден в репозитории '{REPO_ID}'." + logging.warning(msg) + errors.append(msg) + else: + msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {CLIENT_DATA_FILE}: {e}" + logging.error(msg) + errors.append(msg) + except Exception as e: + msg = f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE}: {e}" + logging.error(msg, exc_info=True) + errors.append(msg) + + # Вывод сообщений пользователю + if downloaded_files: + flash(f"Файлы ({', '.join(downloaded_files)}) успешно скачаны и перезаписаны локально.", "success") + if errors: + flash("Во время скачивания произошли ошибки: " + "; ".join(errors), "danger") + if not downloaded_files and not errors: + # Странная ситуация, возможно, нет файлов или другая проблема + flash("Не удалось инициировать скачивание файлов.", "warning") + + # Перезагрузка данных в память после скачивания + try: + logging.info("Перезагрузка данных в память после скачивания...") + load_data() + load_client_data() + logging.info("Данные в памяти обновлены.") + except Exception as e: + logging.error(f"Ошибка при перезагрузке данных после скачивания: {e}", exc_info=True) + flash("Внимание: Файлы скачаны, но произошла ошибка при обновлении данных в приложении. Может потребоваться перезапуск.", "warning") + + + return redirect(url_for('admin_panel')) + +# 8. Маршрут "Отчеты" +@app.route('/reports', methods=['GET']) +def reports(): + # Загружаем основные данные и конфиг + data = load_data() + config = data.get('config', {}) + now = get_current_time() # Текущее время в Бишкеке + + # Получение параметров фильтрации из URL + filter_type = request.args.get('filter', 'month') # По умолчанию - текущий месяц + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + date_str = request.args.get('date') + month_str = request.args.get('month') + year_str = request.args.get('year') + + start_date_dt = None + end_date_dt = None + + try: + # --- Определение временного диапазона --- + if filter_type == 'custom' and start_date_str and end_date_str: + sd = datetime.strptime(start_date_str, '%Y-%m-%d') + ed = datetime.strptime(end_date_str, '%Y-%m-%d') + start_date_dt = BISHKEK_TZ.localize(sd.replace(hour=0, minute=0, second=0, microsecond=0)) + end_date_dt = BISHKEK_TZ.localize(ed.replace(hour=23, minute=59, second=59, microsecond=999999)) + elif filter_type == 'day': + day_to_use_str = date_str if date_str else now.strftime('%Y-%m-%d') + d = datetime.strptime(day_to_use_str, '%Y-%m-%d') + start_date_dt = BISHKEK_TZ.localize(d.replace(hour=0, minute=0, second=0, microsecond=0)) + end_date_dt = start_date_dt.replace(hour=23, minute=59, second=59, microsecond=999999) + elif filter_type == 'week': + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + start_date_dt = today_start - timedelta(days=today_start.weekday()) + end_date_dt = start_date_dt + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) + elif filter_type == 'year': + year_to_use_str = year_str if year_str else str(now.year) + year_int = int(year_to_use_str) + start_date_dt = BISHKEK_TZ.localize(datetime(year_int, 1, 1, 0, 0, 0)) + end_date_dt = BISHKEK_TZ.localize(datetime(year_int, 12, 31, 23, 59, 59, 999999)) + else: # По умолчанию 'month' + month_to_use_str = month_str if month_str else now.strftime('%Y-%m') + year, month = map(int, month_to_use_str.split('-')) + start_date_dt = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0)) + next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) + end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) + + if not start_date_dt or not end_date_dt or start_date_dt > end_date_dt: + raise ValueError("Некорректный временной диапазон.") + + except (ValueError, TypeError) as e: + flash(f"Ошибка в задании периода: {e}. Отображен отчет за текущий месяц.", "warning") + filter_type = 'month' + start_date_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) + end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) + + # --- Фильтрация данных по дате --- + filtered_packed_items = [] + all_packed_items_raw = data.get('qc_packing_items', []) + for item_raw in all_packed_items_raw: + if isinstance(item_raw, dict) and 'id' in item_raw: + item_data = find_item_by_id(item_raw['id'], 'qc_packing_items') + if not item_data: continue + packed_time = parse_iso_datetime(item_data.get('timestamp_packed')) + if packed_time and start_date_dt <= packed_time <= end_date_dt: + shipment_time = None + shipment_details = item_data.get('shipment_details') + if shipment_details and shipment_details.get('timestamp'): + shipment_time = parse_iso_datetime(shipment_details.get('timestamp')) + item_data['shipment_time_dt'] = shipment_time + filtered_packed_items.append(item_data) + + all_defect_log_raw = data.get('defect_log', []) + filtered_defects = [] + for defect_raw in all_defect_log_raw: + if isinstance(defect_raw, dict) and 'log_id' in defect_raw: + defect_data = find_item_by_id(defect_raw['log_id'], 'defect_log') + if not defect_data: continue + defect_time = parse_iso_datetime(defect_data.get('timestamp')) + if defect_time and start_date_dt <= defect_time <= end_date_dt: + filtered_defects.append(defect_data) + + all_expenses_raw = data.get('expenses', []) + filtered_expenses = [] + for expense_raw in all_expenses_raw: + if isinstance(expense_raw, dict) and 'id' in expense_raw: + expense_data = find_item_by_id(expense_raw['id'], 'expenses') + if not expense_data: continue + expense_time = parse_iso_datetime(expense_data.get('timestamp')) + if expense_time and start_date_dt <= expense_time <= end_date_dt: + filtered_expenses.append(expense_data) + + # --- Расчет итоговых показателей за период --- + total_packed_quantity = sum(item.get('quantity', 0) for item in filtered_packed_items) + total_revenue = sum(item.get('packed_final_price', Decimal('0')) for item in filtered_packed_items) + total_material_cost_packed = sum(item.get('packed_material_cost', Decimal('0')) for item in filtered_packed_items) + # Общая ЗП из упакованных товаров (уже включает все 3 этапа) + total_salary_cost_packed = sum(item.get('packed_salary_cost', Decimal('0')) for item in filtered_packed_items) + total_cost_packed = total_material_cost_packed + total_salary_cost_packed + + total_defect_cost = sum(defect.get('cost_dec', Decimal('0')) for defect in filtered_defects) + total_expenses_cost = sum(expense.get('amount', Decimal('0')) for expense in filtered_expenses) + + total_overall_cost = total_cost_packed + total_defect_cost + total_expenses_cost + total_profit = total_revenue - total_overall_cost + + # --- Расчет детализации ЗП по этапам за период --- + total_cutter_salary = Decimal('0') + total_sewer_salary = Decimal('0') + total_packer_salary = Decimal('0') + cutter_rate = to_decimal(config.get('salary_cutter_per_unit', '0')) + sewer_rate = to_decimal(config.get('salary_sewer_per_unit', '0')) + packer_rate = to_decimal(config.get('salary_packer_per_unit', '0')) + + for item in filtered_packed_items: + qty = item.get('quantity', 0) + if qty > 0: + total_cutter_salary += Decimal(qty) * cutter_rate + total_sewer_salary += Decimal(qty) * sewer_rate + total_packer_salary += Decimal(qty) * packer_rate + + calculated_total_salary = total_cutter_salary + total_sewer_salary + total_packer_salary + if total_packed_quantity > 0 and abs(calculated_total_salary - total_salary_cost_packed) > Decimal('0.01') * total_packed_quantity : + logging.warning(f"Расчетная детализация ЗП ({calculated_total_salary}) не совпадает с общей ЗП из упаковок ({total_salary_cost_packed}). Возможны расхождения в логике или округлении.") + + # --- Сводка по продуктам --- + production_summary = {} + for item in filtered_packed_items: + product_name = item.get('product_name', 'Неизвестный продукт') + quantity = item.get('quantity', 0) + revenue = item.get('packed_final_price', Decimal('0')) + cost = item.get('packed_total_cost', Decimal('0')) # Используем общую себестоимость партии + profit = revenue - cost + + if product_name not in production_summary: + production_summary[product_name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')} + + production_summary[product_name]['quantity'] += quantity + production_summary[product_name]['revenue'] += revenue + production_summary[product_name]['cost'] += cost + production_summary[product_name]['profit'] += profit + + # --- Подготовка данных для шаблона --- + report_data = { + 'total_packed_qty': total_packed_quantity, + 'total_revenue': total_revenue, + 'total_material_cost': total_material_cost_packed, + 'total_salary_cost': total_salary_cost_packed, # Общая ЗП + 'total_cost_packed': total_cost_packed, + 'total_defect_cost': total_defect_cost, + 'total_expenses': total_expenses_cost, + 'total_overall_cost': total_overall_cost, + 'total_profit': total_profit, + # Добавлена детализация ЗП + 'total_cutter_salary': total_cutter_salary, + 'total_sewer_salary': total_sewer_salary, + 'total_packer_salary': total_packer_salary, + # --- + 'production_summary': production_summary, + 'filtered_packed_items': filtered_packed_items, + 'filtered_defects': filtered_defects, + 'filtered_expenses': filtered_expenses, + 'start_date': start_date_dt.strftime('%Y-%m-%d'), + 'end_date': end_date_dt.strftime('%Y-%m-%d'), + 'filter_type': filter_type, + 'current_day': now.strftime('%Y-%m-%d'), + 'current_month': now.strftime('%Y-%m'), + 'current_year': now.year, + 'filter_values': request.args + } + + html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS) + return render_template_string(html, report=report_data) # Передаем весь словарь report_data + +# 9. Маршрут "Облако" - НОВЫЙ МАРШРУТ +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx'} + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/cloud', methods=['GET', 'POST']) +def cloud_storage(): + data = load_data() + cloud_files = data.get('cloud_files', []) + + if request.method == 'POST': + description = request.form.get('description', '').strip() + if 'file' not in request.files: + flash('Файл не был выбран.', 'warning') + return redirect(url_for('cloud_storage')) + file = request.files['file'] + if file.filename == '': + flash('Файл не был выбран.', 'warning') + return redirect(url_for('cloud_storage')) + + if file and allowed_file(file.filename): + original_filename = secure_filename(file.filename) + # Генерируем уникальное имя файла для хранения + file_ext = original_filename.rsplit('.', 1)[1].lower() + stored_filename = f"{uuid.uuid4().hex}.{file_ext}" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) + thumbnail_filename = None + + try: + file.save(file_path) + logging.info(f"Файл '{original_filename}' сохранен как '{stored_filename}'") + + # Создаем миниатюру, если это изображение + if file_ext in {'png', 'jpg', 'jpeg', 'gif'}: + thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], stored_filename) + thumbnail_filename = create_thumbnail(file_path, thumb_path) # Вернет имя файла или None + + # Сохраняем метаданные + file_meta = { + 'file_id': uuid.uuid4().hex, # ID для управления записью + 'original_filename': original_filename, + 'stored_filename': stored_filename, + 'thumbnail_filename': thumbnail_filename, # Имя файла миниатюры или None + 'description': description, + 'timestamp': get_current_time().isoformat(), + 'size': os.path.getsize(file_path) # Размер файла в байтах + } + cloud_files.append(file_meta) + data['cloud_files'] = cloud_files + save_data(data) + flash(f"Файл '{original_filename}' успешно загружен.", 'success') + upload_db_to_hf(DATA_FILE) # Бэкап + + except Exception as e: + logging.error(f"Ошибка при сохранении файла или создании миниатюры: {e}", exc_info=True) + flash(f"Ошибка при загрузке файла: {e}", 'danger') + # Удаляем частично загруженный файл, если он есть + if os.path.exists(file_path): + try: os.remove(file_path) + except OSError: pass + + return redirect(url_for('cloud_storage')) + else: + flash('Недопустимый тип файла.', 'danger') + return redirect(url_for('cloud_storage')) + + # GET запрос + search_query = request.args.get('search', '').lower() + if search_query: + filtered_files = [ + f for f in cloud_files + if search_query in f.get('description', '').lower() or \ + search_query in f.get('original_filename', '').lower() + ] + else: + filtered_files = cloud_files + + # Сортируем по дате добавления (сначала новые) + filtered_files.sort(key=lambda x: x.get('timestamp', ''), reverse=True) + + html = BASE_TEMPLATE.replace('__TITLE__', "Облачное хранилище").replace('__CONTENT__', CLOUD_CONTENT).replace('__SCRIPTS__', CLOUD_SCRIPTS) + return render_template_string(html, files=filtered_files, search_query=search_query) + + +# 10. Маршрут для скачивания файла из облака - НОВЫЙ +@app.route('/download_file/') +def download_file(filename): + # Важно: НЕ используйте secure_filename здесь, т.к. имя файла уже уникально + # и может не совпадать с оригинальным! + try: + # Используем send_from_directory для безопасной отправки файла + return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True) + except FileNotFoundError: + flash("Файл не найден.", "danger") + return redirect(url_for('cloud_storage')) + except Exception as e: + logging.error(f"Ошибка при скачивании файла {filename}: {e}", exc_info=True) + flash(f"Ошибка при скачивании файла: {e}", "danger") + return redirect(url_for('cloud_storage')) + +# 11. Маршрут для отображения миниатюр - НОВЫЙ +@app.route('/thumbnail/') +def get_thumbnail(filename): + try: + return send_from_directory(app.config['THUMBNAIL_FOLDER'], filename) + except FileNotFoundError: + # Можно вернуть заглушку или 404 + return send_from_directory('static', 'placeholder.png') # Предполагая, что есть static/placeholder.png + except Exception as e: + logging.error(f"Ошибка при отдаче миниатюры {filename}: {e}") + return send_from_directory('static', 'placeholder.png') + +# 12. Маршрут для удаления файла из облака - НОВЫЙ +@app.route('/cloud/delete/', methods=['POST']) +def delete_cloud_file(file_id): + data = load_data() + cloud_files = data.get('cloud_files', []) + file_to_delete = None + file_index = -1 + + for i, f in enumerate(cloud_files): + if f.get('file_id') == file_id: + file_to_delete = f + file_index = i + break + + if file_to_delete: + stored_filename = file_to_delete.get('stored_filename') + thumb_filename = file_to_delete.get('thumbnail_filename') + file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) if stored_filename else None + thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_filename) if thumb_filename else None + + try: + # Удаляем запись из данных + del cloud_files[file_index] + data['cloud_files'] = cloud_files + save_data(data) # Сохраняем изменения в JSON + + # Удаляем физический файл + if file_path and os.path.exists(file_path): + os.remove(file_path) + logging.info(f"Удален файл: {file_path}") + # Удаляем миниатюру + if thumb_path and os.path.exists(thumb_path): + os.remove(thumb_path) + logging.info(f"Удалена миниатюра: {thumb_path}") + + flash(f"Файл '{file_to_delete.get('original_filename', 'N/A')}' успешно удален.", 'success') + upload_db_to_hf(DATA_FILE) # Бэкап + + except Exception as e: + logging.error(f"Ошибка при удалении файла {file_id}: {e}", exc_info=True) + flash(f"Ошибка при удалении файла: {e}", 'danger') + # Важно: Не откатываем удаление записи из JSON, если файл не удалился, + # чтобы избежать рассинхронизации. Лучше иметь запись без файла, чем файл без записи. + else: + flash('Файл для удаления не найден.', 'warning') + + return redirect(url_for('cloud_storage')) + + +# --- HTML Шаблоны --- + +# ОБЫЧНЫЙ Базовый шаблон (с навигацией) - Добавлен маршрут Облако +BASE_TEMPLATE = """ + + + + + + __TITLE__ - КШП {# Title Changed #} + + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + {% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + __CONTENT__ + +
+ +
+
+

© {{ get_current_time().year }} КШП. Все права защищены. ({{ get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') }})

{# Title Changed #} +
+
+ + + + + + +__SCRIPTS__ + + +""" + +# ОПЕРАЦИОННЫЙ Базовый шаблон (БЕЗ навигации и БЕЗ кнопки "Назад") +BASE_TEMPLATE_OPERATIONAL = """ + + + + + + __TITLE__ - КШП {# Title Changed #} + + + + + + + +{# --- НАВИГАЦИЯ УБРАНА --- #} + +
+ {# --- КНОПКА НАЗАД УБРАНА --- #} + +

__TITLE__

{# Добавлен заголовок страницы #} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + {% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + __CONTENT__ + +
+ +
+
+

© {{ get_current_time().year }} КШП. Все права защищены. ({{ get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') }})

{# Title Changed #} +
+
+ + + + + + +__SCRIPTS__ + + +""" + +# Контент закупки +PROCUREMENT_CONTENT = """ +
+
Добавить закупленные материалы
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
{# Увеличил ширину для цены #} + + +
+
+ + + На ск-ко изд. +
+
+ + +
+
{# Уменьшил ширину категории #} + + + +
+ +
+ +
+ + +
+
+
+""" + +# Скрипты закупки +PROCUREMENT_SCRIPTS = """ + +""" + +# Контент раскроя +CUTTING_CONTENT = """ +
+
Регистрация раскроя
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+""" + +# Скрипты раскроя +CUTTING_SCRIPTS = """ + +""" + +# Контент пошива +SEWING_CONTENT = """ +
+
Регистрация пошива
+
+ {% if cutting_tasks %} +
+ {# Выбор задания на раскрой и Название изделия #} +
+
+ + +
+
+ + +
+
+ + {# Детали задания на раскрой (БЕЗ ЦЕН/ЗП) #} + + + {# Количество сшитых #} +
+
+ + +
Не больше, чем было раскроено (0).
+
+
+ +
+ {# Добавление фурнитуры #} +
Добавить фурнитуру для ЭТОГО пошива: (Опционально)
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ {# Регистрация брака #} +
Регистрация брака материалов (если есть): (Опционально)
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ +
+ {% else %} + + {% endif %} +
+
+""" + +# Скрипты пошива +SEWING_SCRIPTS = """ + +""" + +# Контент ОТК +QC_PACKING_CONTENT = """ +
+
ОТК и Упаковка готовых изделий
+
+ {% if sewing_tasks %} +
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ {% else %} + + {% endif %} +
+
+""" + +# Скрипты ОТК +QC_PACKING_SCRIPTS = """ + +""" + +# Контент Базы Клиентов +CLIENTS_CONTENT = """ +
+

База клиентов

+
+ + +
+
Добавить нового клиента
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
Список клиентов
+
+
+ +
+
+ {# Добавлен table-bordered #} + + + + + + + + + + + {% for client in clients %} {# Цикл по клиентам #} + + + + + + + + {% else %} + + + + {% endfor %} {# Конец цикла по клиентам #} + +
ID Имя / Организация Телефон Адрес Действия
{{ client.id[:8] }}...{{ client.name }}{{ client.phone }}{{ client.address | default('-') | safe }} + +
Клиенты еще не добавлены.
+
+
+
+ + +{% for client in clients %} {# Цикл по клиентам для создания модальных окон #} + +{% endfor %} {# Конец цикла по клиентам для модальных окон #} +""" + + +# Скрипты Базы Клиентов +CLIENTS_SCRIPTS = """ + +""" + +# Контент Админ-панели - ИЗМЕНЕНО: Добавлена вкладка для истории Дордоя, фильтр для материалов +ADMIN_CONTENT = """ +

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

+

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

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

{{ materials_count }}

позиций на складе
+
Ожидают пошива

{{ pending_cutting_count }}

заданий раскроя
+
Ожидают ОТК

{{ pending_qc_count }}

заданий пошива ({{ format_integer_py(pending_qc_quantity) }} шт. в них)
+
Упаковано Всего

{{ format_integer_py(total_packed_count) }}

готовых изделий (все стадии)
+
Готово к отправке

{{ format_integer_py(items_ready_ship_qty) }}

шт. в {{ items_ready_ship_count }} партиях
+
Брак (Всего)

Ткань: {{ total_defect_fabric_m }} м

Фурнитура: {{ total_defect_fittings_pcs }} шт.

Готовые изд.: {{ total_defect_finished_pcs }} шт.

Стоимость: {{ total_defect_cost }} сом

за все время
+
+ + +
+
Настройки зарплат и маржи
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
Управление категориями материалов
+
+
Добавить
Удалить
{% if categories and categories|reject('equalto', 'Без категории')|list %}
{% else %}

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

{% endif %}

Существующие:
{% if categories %}
    {% for category in categories %}
  • {{ category }}
  • {% endfor %}
{% else %}

Нет категорий.

{% endif %} +
+
+ + +
+
Дополнительные расходы
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
Журнал доп. расходов:
+
+ {# Добавлен table-bordered #} + + + {% for expense in expenses|sort(attribute='timestamp', reverse=True) %} {# Сортировка по дате #} + + + + + + + {% else %} + + {% endfor %} + +
IDОписаниеСумма (сом)Дата
{{ expense.id[:8] }}...{{ expense.description }}{{ format_currency_py(expense.amount) }}{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }}
Дополнительные расходы еще не добавлялись.
+
+
+
+ + + + + +
+ +
+
Список материалов
+ +
{# Добавлен table-bordered #} + + + + + + + + + + + + {% set found_positive = false %} {# Флаг для проверки, есть ли материалы > 0 #} + {% for m in materials|sort(attribute='name') %} + {# --- ИЗМЕНЕНО: Показываем только материалы с количеством > 0 --- #} + {% if m.quantity > 0 %} + {% set found_positive = true %} + + + + + + + + + + + {% endif %} + {# --- КОНЕЦ ИЗМЕНЕНИЯ --- #} + {% endfor %} + {% if not found_positive %} + + {% endif %} +
ID Название Категория Тип Кол-во Ед.изм.Цена/ед На ед. Добавлен Обновлен
{{ m.id[:8] }}...{{ m.name }}{{ m.category | default('Без категории') }}{{ 'Ткань' if m.type == 'fabric' else 'Фурнитура' }}{{ format_currency_py(m.quantity) if m.type == 'fabric' else format_integer_py(m.quantity) }}{{ m.unit }}{{ format_currency_py(m.price_per_unit) }}{{ format_integer_py(m.items_per_unit) }}{{ m.timestamp_added[:16] | replace('T', ' ') if m.timestamp_added else 'N/A' }}{{ m.timestamp_last_updated[:16] | replace('T', ' ') if m.timestamp_last_updated else 'N/A' }}
Нет материалов в наличии.
+
+ +
+
Задания на раскрой
+
{# Добавлен table-bordered #} + + + {% for task in cutting_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #} + + + + + + + + + {% else %}{% endfor %} +
IDТканьКол-воРасходСтатусСозданоЗавершено
{{ task.id[:8] }}...{{ task.fabric_name }}({{ task.fabric_id[:6] }}...){{ format_integer_py(task.cut_items_quantity) }}{{ format_currency_py(task.fabric_used) }} {{ task.fabric_unit }}{{ getStatusText(task.status) }}{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }}{{ task.timestamp_completed[:16] | replace('T', ' ') if task.timestamp_completed else '-' }}
Нет заданий на раскрой.
+
+ +
+
Задания на пошив
+
{# Добавлен table-bordered #} + + + {% for task in sewing_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #} + + + + + + + + + + + {% else %}{% endfor %} +
IDИзделиеСшитоИсп. фурн.Брак пошиваСтатусУпак./Брак ОТКСозданоЗавершеноРаскрой ID
{{ task.id[:8] }}...{{ task.product_name }}{{ format_integer_py(task.sewn_quantity) }} + {% if task.fittings_consumed is iterable and task.fittings_consumed is not string %} {# Доп. проверка #} +
    + {% for f in task.fittings_consumed %} + {% if f is mapping %} {# Проверка, что f - словарь #} +
  • {{ f.get('fitting_name','?') }}: {{ format_integer_py(f.get('quantity_used',0)) }} шт.
  • + {% endif %} + {% endfor %} +
+ {% else %}-{% endif %} +
+ {% if task.defects_reported is iterable and task.defects_reported is not string %} {# Доп. проверка #} +
    + {% for d in task.defects_reported %} + {% if d is mapping %} {# Проверка, что d - словарь #} +
  • {{ d.get('material_name','?') }}: + {% set dtype = d.get('type') %} + {% set dqty = d.get('quantity',0) %} + {% if dtype == 'fabric' %}{{ format_currency_py(dqty) }} + {% else %}{{ format_integer_py(dqty) }} + {% endif %} {{ d.get('unit','?') }} +
  • + {% endif %} + {% endfor %} +
+ {% else %}-{% endif %} +
{{ getStatusText(task.status) }}{{ format_integer_py(task.qc_packed_quantity) }} / {{ format_integer_py(task.qc_defective_quantity) }}{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }}{{ task.timestamp_completed[:16] | replace('T', ' ') if task.timestamp_completed else '-' }}{{ task.cutting_task_id[:8] }}...
Нет заданий на пошив.
+
+ +
+
Упакованные изделия (все)
+
{# Добавлен table-bordered #} + + + {% for item in packed_items|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #} + {% set qty = item.quantity if item.quantity > 0 else 1 %} {# item.quantity уже int #} + {% set cost_per_item = item.packed_total_cost / qty if qty > 0 else 0 %} {# item.packed_total_cost уже Decimal #} + {% set price_per_item = item.packed_final_price / qty if qty > 0 else 0 %} {# item.packed_final_price уже Decimal #} + + + + + + + + + + + {% else %}{% endfor %} +
IDНазваниеКол-воСебест. (ед.)Цена (ед.)Общая себест.Общая ценаСтатусДата упак.Детали отправкиПошив ID
{{ item.id[:8] }}...{{ item.product_name }}{{ format_integer_py(item.quantity) }}{{ format_currency_py(cost_per_item) }}{{ format_currency_py(price_per_item) }}{{ format_currency_py(item.packed_total_cost) }}{{ format_currency_py(item.packed_final_price) }}{{ getStatusText(item.status) }}{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }} + {% set details = item.shipment_details %} + {% if details is mapping %} {# Проверка что details это словарь #} + + {{ details.get('timestamp')[:16] | replace('T',' ') if details.get('timestamp') else '' }} + {% if details.get('type') == 'client' %} +
Клиент: {{ details.get('client_name', 'N/A')}} + {% elif details.get('type') == 'dor_doi_point' %} +
Назначение: {{ details.get('destination', 'Дордой') }} + {% endif %} +
+ {% else %} - {% endif %} +
{{ item.sewing_task_id[:8] }}...
Нет упакованных изделий.
+
+ +
+
Готово к отправке
+ {% if items_ready_to_ship %} +
{# Добавлен table-bordered #} + + + + {% for item in items_ready_to_ship|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #} + + + + + + + + + + {% endfor %} + +
IDИзделиеКол-воСебест.(общ)Цена (общ)Дата упак.Действия
{{ item.id[:8] }}...{{ item.product_name }}{{ format_integer_py(item.quantity) }}{{ format_currency_py(item.packed_total_cost) }}{{ format_currency_py(item.packed_final_price) }}{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }} +
+ +
+ +
+ +
+ +
+
+
+ {% else %} + + {% endif %} +
+
+ {# --- НАЧАЛО: Вкладка истории Дордоя --- #} +
+
История отправок на Дордой
+ {% if dordoi_shipments %} +
+ + + {% for shipment in dordoi_shipments %} {# Уже отсортировано в Python #} + + + + + + + {% endfor %} +
ID ОтправкиДатаТоварыID Упаковки
{{ shipment.shipment_id[:8] }}...{{ shipment.timestamp_dt.strftime('%Y-%m-%d %H:%M') if shipment.timestamp_dt else shipment.timestamp[:16]|replace('T',' ') }} + {% if shipment.items is iterable and shipment.items is not string and shipment.items %} +
    + {% for item in shipment.items %} +
  • {{ item.get('product_name', '?') }}: {{ item.get('quantity', '?') }} шт.
  • + {% endfor %} +
+ {% else %}-{% endif %} +
{{ shipment.packed_item_id[:8] }}...
+ {% else %} + + {% endif %} +
+
+ {# --- КОНЕЦ: Вкладка истории Дордоя --- #} + +
+
Журнал брака
+
{# Добавлен table-bordered #} + + + + {% for defect in defect_log|sort(attribute='timestamp', reverse=True) %} {# Сортировка по дате брака #} + + + + + {# Используем поле _view #} + + {# Используем поле _dec #} + + + + + {% else %}{% endfor %} +
IDМатериал/ИзделиеТипКол-воЕд.СтоимостьЭтапПричинаДатаПошив ID
{{ defect.log_id[:8] }}...{{ defect.material_name }} {% if defect.material_id %}({{ defect.material_id[:6] }}...){% endif %}{{ defect.type|replace('_', ' ')|title }}{{ defect.quantity_view }}{{ defect.unit }}{{ format_currency_py(defect.cost_dec) }}{{ defect.stage|replace('_', ' ')|title }}{{ defect.reason | default('-') }}{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...
Записи о браке отсутствуют.
+
+
+""" + +# Скрипты Админ-панели +ADMIN_SCRIPTS = """ + +""" + +# Контент Отчетов - ИЗМЕНЕНО +REPORTS_CONTENT = """ +
+

Отчеты

+
+ + +
+
Фильтр периода
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +

Отчет за период: {{ report.start_date }} - {{ report.end_date }}

+
+
+
+
+
Выручка
+

{{ format_currency_py(report.total_revenue) }}

+ сом ({{ format_integer_py(report.total_packed_qty) }} шт. упаковано в период) +
+
+
+
+
+
+
Прибыль
+

{{ format_currency_py(report.total_profit) }}

+ сом (Выручка - Затраты) +
+
+
+
+ {# --- ИЗМЕНЕНО: Карточка Общих Затрат стала кликабельной --- #} +
+
+
Затраты (Общие)
+

{{ format_currency_py(report.total_overall_cost) }}

+ сом (Себест.Упак.+Брак+Доп.) (Нажмите для деталей) +
+
+ {# --- КОНЕЦ ИЗМЕНЕНИЯ --- #} +
+
+
+
+
Затраты на брак
+

{{ format_currency_py(report.total_defect_cost) }}

+ сом ({{ report.filtered_defects|length }} записей) +
+
+
+
+ {# --- ИЗМЕНЕНО: Карточка ЗП стала кликабельной --- #} +
+
+
ЗП (Упакованные)
+

Материалы: {{ format_currency_py(report.total_material_cost) }} сом

+

Зарплаты: {{ format_currency_py(report.total_salary_cost) }} сом

+

Итого себестоимость: {{ format_currency_py(report.total_cost_packed) }} сом

+ За {{ format_integer_py(report.total_packed_qty) }} шт. (Нажмите для деталей ЗП) +
+
+ {# --- КОНЕЦ ИЗМЕНЕНИЯ --- #} +
+
+
+
+
Доп. расходы
+

{{ format_currency_py(report.total_expenses) }}

+ сом ({{ report.filtered_expenses|length }} записей) +
+
+
+
+ + + + +
+ +
+
Сводка по продуктам за период (по дате упаковки)
+
{# Добавлен table-bordered #} + + + {% for name, summary in report.production_summary.items() %} + {% set avg_profit = (summary.profit / summary.quantity) if summary.quantity > 0 else 0 %} + + + + + + + + {% else %}{% endfor %} +
ПродуктУпаковано (шт)Выручка (сом)Себестоимость (сом)Прибыль (сом)Средняя прибыль/шт
{{ name }}{{ format_integer_py(summary.quantity) }}{{ format_currency_py(summary.revenue) }}{{ format_currency_py(summary.cost) }}{{ format_currency_py(summary.profit) }}{{ format_currency_py(avg_profit) }}
Нет данных об упакованных товарах за этот период.
+
+ +
+
Упакованные изделия за период
+
{# Добавлен table-bordered #} + + + {% for item in report.filtered_packed_items|sort(attribute='timestamp_packed', reverse=True) %} + {% set qty = item.quantity if item.quantity > 0 else 1 %} + {% set cost_per_item = item.packed_total_cost / qty if qty > 0 else 0 %} + {% set price_per_item = item.packed_final_price / qty if qty > 0 else 0 %} + + + + + + + + + + + {% else %}{% endfor %} +
IDНазваниеКол-воСебест. (ед.)Цена (ед.)Общ. себест.Общ. ценаДата упак.СтатусДетали отправкиПошив ID
{{ item.id[:8] }}...{{ item.product_name }}{{ format_integer_py(item.quantity) }}{{ format_currency_py(cost_per_item) }}{{ format_currency_py(price_per_item) }}{{ format_currency_py(item.packed_total_cost) }}{{ format_currency_py(item.packed_final_price) }}{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}{{ getStatusText(item.status) }} + {% set details = item.shipment_details %} + {% if details is mapping %} {# Проверка что details это словарь #} + + {# Используем поле _dt для форматированной даты отправки #} + {{ item.shipment_time_dt.strftime('%Y-%m-%d %H:%M') if item.shipment_time_dt else details.get('timestamp', '')[:16]|replace('T',' ') }} + {% if details.get('type') == 'client' %} +
Клиент: {{ details.get('client_name', 'N/A')}} + {% elif details.get('type') == 'dor_doi_point' %} +
Назначение: {{ details.get('destination', 'Дордой') }} + {% endif %} +
+ {% elif item.status == 'packed_ready_to_ship' %} - {# Готово, но не отправлено #} + {% else %} - {# Другой статус или нет данных #} {% endif %} +
{{ item.sewing_task_id[:8] }}...
Нет упакованных изделий за этот период.
+
+ +
+
Брак за период
+
{# Добавлен table-bordered #} + + + + {% for defect in report.filtered_defects|sort(attribute='timestamp', reverse=True) %} + + + + + + + + + + + {% else %}{% endfor %} +
IDМатериал/ИзделиеТипКол-воЕд.СтоимостьЭтапПричинаДатаПошив ID
{{ defect.log_id[:8] }}...{{ defect.material_name }} {% if defect.material_id %}({{ defect.material_id[:6] }}...){% endif %}{{ defect.type|replace('_', ' ')|title }}{{ defect.quantity_view }}{{ defect.unit }}{{ format_currency_py(defect.cost_dec) }}{{ defect.stage|replace('_', ' ')|title }}{{ defect.reason | default('-') }}{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...
Нет записей о браке за этот период.
+
+ +
+
Доп. ��асходы за период
{# Добавлен bg-white #} +
{# Добавлен table-bordered #} + + + {% for expense in report.filtered_expenses|sort(attribute='timestamp', reverse=True) %} + + + + + {% else %}{% endfor %} +
IDОписаниеСумма (сом)Дата
{{ expense.id[:8] }}...{{ expense.description }}{{ format_currency_py(expense.amount) }}{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }}
Нет доп. расходов за этот период.
+
+
+ +{# --- Модальное окно для детализации ЗП --- #} + +{# --- КОНЕЦ: Модальное окно для детализации ЗП --- #} + +{# --- НАЧАЛО: Модальное окно для детализации Затрат --- #} + +{# --- КОНЕЦ: Модальное окно для детализации Затрат --- #} +""" + +# Скрипты Отчетов +# ... (REPORTS_SCRIPTS без изменений) ... +REPORTS_SCRIPTS = """ + +""" + +# Контент Облака - НОВЫЙ +CLOUD_CONTENT = """ +
+

Облачное хранилище

+
+ + +
+
Загрузить новый файл
+
+
+
+ + +
+
+ + + Допустимые типы файлов: png, jpg, jpeg, gif, pdf, txt, doc, docx, xls, xlsx. Макс. размер: 16MB. +
+ +
+
+
+ + +
+
Список файлов в облаке
+
{# Явно устанавливаем белый фон #} +
+
+ + +
+
+ + {% if files %} +
+ {% for file in files %} +
+
+ {% if file.thumbnail_filename %} + Миниатюра {{ file.original_filename }} + {% endif %} +
+
{{ file.original_filename }}
+ {% if file.description %} +

{{ file.description }}

+ {% endif %} +

Размер: {{ (file.size / 1024)|round(1) }} Кб

+
+ Скачать +
+ +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} + + {% endif %} +
+
+""" + +# Скрипты Облака +CLOUD_SCRIPTS = """ + +""" + +# --- Конец HTML Шаблонов --- + +# Добавляем утилиты в контекст Jinja +@app.context_processor +def inject_utils(): + return { + 'get_current_time': get_current_time, + 'getStatusText': getStatusText, + 'getStatusClass': getStatusClass, + 'format_currency_py': format_currency_py, + 'format_integer_py': format_integer_py + } + +if __name__ == '__main__': + # Запускаем поток для периодического бэкапа + backup_thread = threading.Thread(target=periodic_backup, daemon=True) + backup_thread.start() + + try: + logging.info("Первоначальная загрузка данных...") + load_data() + load_client_data() + logging.info("Данные успешно загружены/инициализированы.") + except Exception as e: + logging.critical(f"Не удалось загрузить базы данных при запуске: {e}", exc_info=True) + + logging.info("Запуск Flask приложения на http://0.0.0.0:7860") + app.run(debug=True, host='0.0.0.0', port=7860, use_reloader=False)