diff --git "a/app.py" "b/app.py" deleted file mode 100644--- "a/app.py" +++ /dev/null @@ -1,4014 +0,0 @@ - -# Импортируем необходимые библиотеки -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/testbasebase" # !!! Обновленный 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': [], - 'advances': [], # Добавлено для авансов - 'monthly_salaries': {}, # Добавлено для зарплат - 'orders': [], # Добавле��о для заказов - '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'] - # ИЗМЕНЕНИЕ: было items_per_unit стало material_per_unit, но это int - int_fields = ['material_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): - # Преобразуем quantity_used в int, cost в Decimal - 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 - elif item_list_name == 'advances': # Добавлено для авансов - decimal_fields = ['amount'] - elif item_list_name == 'orders': # Добавлено для заказов - int_fields = ['items_quantity'] - decimal_fields = ['prepayment', 'fabric_quantity'] # Предоплата, кол-во ткани - # Обработка fittings внутри заказа - if 'fittings' in item_copy and isinstance(item_copy['fittings'], list): - for f in item_copy['fittings']: - if isinstance(f, dict): - # Преобразуем quantity в int - try: f['quantity'] = int(to_decimal(f.get('quantity', '0'))) - except (InvalidOperation, ValueError): f['quantity'] = 0 - # Для 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: - # Устанавливаем Decimal('0.00') по умолчанию для отсутствующих decimal полей - 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: - # Устанавливаем 0 по умолчанию для отсутствующих int полей - 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': 'Отправлено на Дордой', - 'pending_procurement': 'Ожидает закупа' # Статус для заказов - } - 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 - 'pending_procurement': 'status-procurement text-secondary' # Статус для заказов - # 'partially_shipped': 'status-partially-shipped text-primary' # Можно добавить - } - return classMap.get(statusKey, '') # Возвращаем пустую строку, если статус неизвестен - -# --- Маршруты Flask --- - -@app.route('/') -def index(): - # Перенаправляем на админ-панель по умолчанию - return redirect(url_for('admin_panel')) - -# Маршрут "Заказы" -@app.route('/orders', methods=['GET', 'POST']) -def orders(): - data = load_data() - clients_data = load_client_data() - - # Get materials for selection (not strictly needed for the current version of the form) - fabrics = [m for m in data.get('materials', []) - if isinstance(m, dict) and m.get('type') == 'fabric'] - fittings = [m for m in data.get('materials', []) - if isinstance(m, dict) and m.get('type') == 'fittings'] - - if request.method == 'POST': - try: - client_id = request.form.get('client_id') - model_name = request.form.get('model_name', '').strip() - fabric_name = request.form.get('fabric_name', '').strip() # Теперь это просто текстовое поле - fabric_quantity_str = request.form.get('fabric_quantity') # Количество ткани - size_range = request.form.get('size_range', '').strip() - items_quantity_str = request.form.get('items_quantity') - prepayment_str = request.form.get('prepayment', '0') # Предоплата - - fitting_names = request.form.getlist('fitting_names[]') - fitting_quantities = request.form.getlist('fitting_quantities[]') - - if not all([client_id, model_name, fabric_name, fabric_quantity_str, items_quantity_str]): - flash("Заполните все обязательные поля заказа (клиент, модель, ткань, кол-во ткани, кол-во изделий).", "danger") - return redirect(url_for('orders')) - - # Find client - client = find_client_by_id(client_id) - if not client: - flash("Выбранный клиент не найден.", "danger") - return redirect(url_for('orders')) - - # Validate numbers - try: - fabric_quantity = to_decimal(fabric_quantity_str) - if fabric_quantity <= 0: raise ValueError("Количество ткани должно быть > 0") - except (InvalidOperation, ValueError): - flash("Некорректное количество ткани.", "danger") - return redirect(url_for('orders')) - - try: - items_quantity = int(to_decimal(items_quantity_str).to_integral_value()) - if items_quantity <= 0: raise ValueError("Количество изделий должно быть > 0") - except (InvalidOperation, ValueError): - flash("Некорректное количество изделий.", "danger") - return redirect(url_for('orders')) - - try: - prepayment = to_decimal(prepayment_str) - if prepayment < 0: raise ValueError("Предоплата не может быть отрицательной") - except (InvalidOperation, ValueError): - flash("Некорректная сумма предоплаты.", "danger") - return redirect(url_for('orders')) - - # Create new order - creation_time = get_current_time().isoformat() - new_order = { - 'id': uuid.uuid4().hex, - 'client_id': client_id, - 'client_name': client.get('name', 'N/A'), # Сохраняем имя клиента для удобства - 'model_name': model_name, - 'fabric_name': fabric_name, # Сохраняем название ткани - 'fabric_quantity': str(fabric_quantity), # Сохраняем как строку - 'size_range': size_range, - 'items_quantity': items_quantity, # int - 'prepayment': str(prepayment), # Сохраняем как строку - 'status': 'pending_procurement', # Initial status - ожидает закупа - 'timestamp_created': creation_time, - 'timestamp': creation_time, # Общее время (можно использовать для сортировки) - 'is_procured': False, # Явно указываем, что закуп еще не выполнен - 'fittings': [] # Список необходимой фурнитуры - } - - # Add fittings if any - for i in range(len(fitting_names)): - name = fitting_names[i].strip() - qty_str = fitting_quantities[i].strip() - if name and qty_str: - try: - qty_int = int(to_decimal(qty_str).to_integral_value()) - if qty_int > 0: - new_order['fittings'].append({ - 'fitting_name': name, # Название фурнитуры - 'quantity': qty_int # Количество (int) - }) - else: - flash(f"Предупреждение: Количество для фурнитуры '{name}' должно быть положительным.", "warning") - except (InvalidOperation, ValueError): - flash(f"Предупреждение: Некорректное количество для фурнитуры '{name}'.", "warning") - - # Initialize orders list if not exists - if 'orders' not in data or not isinstance(data['orders'], list): - data['orders'] = [] - - # Add new order and save - with data_lock: - data['orders'].append(new_order) - save_data(data) - - flash(f"Заказ на {items_quantity} ед. '{model_name}' успешно создан. Статус: Ожидает закупа.", "success") - upload_db_to_hf(DATA_FILE) - return redirect(url_for('orders')) - - except Exception as e: - logging.error(f"Ошибка при создании заказа: {e}", exc_info=True) - flash(f"Произошла ошибка при создании заказа: {e}", "danger") - return redirect(url_for('orders')) - - # GET запрос - отображаем форму и список заказов - orders_list_raw = data.get('orders', []) - orders_list = [] - # Преобразуем данные для отображения - for order_raw in orders_list_raw: - if isinstance(order_raw, dict) and 'id' in order_raw: - order_data = find_item_by_id(order_raw['id'], 'orders') - if order_data: - orders_list.append(order_data) - - # Убедимся что все заказы имеют timestamp_created - for order in orders_list: - if isinstance(order, dict) and not order.get('timestamp_created'): - order['timestamp_created'] = order.get('timestamp', '') # Используем timestamp как запасной вариант - - # Сортируем заказы по дате создания (новые сверху) - orders_list.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) - - html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Заказы").replace('__CONTENT__', ORDERS_CONTENT).replace('__SCRIPTS__', ORDERS_SCRIPTS) - return render_template_string(html, - clients=clients_data, - # fabrics and fittings не нужны для текущей формы - orders=orders_list) - -# Маршрут редактирования заказа (НЕ РЕАЛИЗОВАН в HTML, но оставлен код) -@app.route('/orders/edit/', methods=['POST']) -def edit_order(order_id): - # ВНИМАНИЕ: Эта функция не вызывается из текущего HTML. - # Необходимо добавить кнопку/модальное окно для редактирования в ORDERS_CONTENT - # и соответствующий JavaScript в ORDERS_SCRIPTS. - data = load_data() - - # Находим заказ для редактирования - orders = data.get('orders', []) - order_to_edit = None - order_index = -1 - for i, order in enumerate(orders): - if isinstance(order, dict) and order.get('id') == order_id: - order_to_edit = order # Работаем с оригиналом - order_index = i - break - - if order_index == -1: - flash("Заказ не найден.", "danger") - return redirect(url_for('orders')) - - try: - # Обновляем данные заказа (пример) - # Нужно получить данные из формы редактирования - new_model_name = request.form.get('edit_model_name', '').strip() - new_fabric_name = request.form.get('edit_fabric_name', '').strip() - new_fabric_quantity_str = request.form.get('edit_fabric_quantity') - new_items_quantity_str = request.form.get('edit_items_quantity') - # ... другие поля ... - - if new_model_name: order_to_edit['model_name'] = new_model_name - if new_fabric_name: order_to_edit['fabric_name'] = new_fabric_name - # ... валидация и обновление других полей ... - - # Обновляем фурнитуру (пример) - # fitting_names = request.form.getlist('edit_fitting_names[]') - # fitting_quantities = request.form.getlist('edit_fitting_quantities[]') - # order_to_edit['fittings'] = [] - # ... логика добавления фурнитуры ... - - with data_lock: - # orders[order_index] уже обновлен, т.к. order_to_edit - ссылка - save_data(data) - flash("Заказ успешно обновлен.", "success") - upload_db_to_hf(DATA_FILE) - - except Exception as e: - logging.error(f"Ошибка при обновлении заказа {order_id}: {e}", exc_info=True) - flash(f"Произошла ошибка при обновлении заказа: {e}", "danger") - - return redirect(url_for('orders')) - -# Маршрут удаления заказа -@app.route('/orders/delete/', methods=['POST']) -def delete_order(order_id): - data = load_data() - orders = data.get('orders', []) - order_deleted = False - deleted_name = "N/A" - - # Находим и удаляем заказ - new_orders_list = [] - for order in orders: - if isinstance(order, dict) and order.get('id') == order_id: - deleted_name = order.get('model_name', 'N/A') - # Можно добавить проверку статуса, если нужно запретить удаление выполненных - # if order.get('status') != 'completed': - # order_deleted = True - # else: - # flash("Нельзя удалить выполненный заказ.", "danger") - # new_orders_list.append(order) # Оставляем в списке - order_deleted = True # Позволяем удалять любой - else: - new_orders_list.append(order) - - if order_deleted: - with data_lock: - data['orders'] = new_orders_list - save_data(data) - flash(f"Заказ '{deleted_name}' успешно удален.", "success") - upload_db_to_hf(DATA_FILE) - else: - # Если не удалили из-за статуса (если раскомментировать проверку выше) - # или если просто не нашли - if not any(o.get('id') == order_id for o in orders if isinstance(o, dict)): - flash("Заказ не найден.", "warning") - - return redirect(url_for('orders')) - -# 1. Маршрут "Закуп" -@app.route('/procurement', methods=['GET', 'POST']) -def procurement(): - data = load_data() - categories = data.get('categories', []) - - # Получаем заказы, ожидающие закупа - pending_orders_raw = data.get('orders', []) - pending_orders = [] - for order_raw in pending_orders_raw: - if isinstance(order_raw, dict) and order_raw.get('status') == 'pending_procurement' and not order_raw.get('is_procured', False): - order_data = find_item_by_id(order_raw['id'], 'orders') # Получаем преобразованные данные - if order_data: - pending_orders.append(order_data) - - if request.method == 'POST': - # --- Обработка отметки заказа как "закуплен" --- - order_id_procured = request.form.get('order_id') # Имя поля из формы отметки - if order_id_procured: - order_updated = False - current_orders = data.get('orders', []) - for order in current_orders: - if isinstance(order, dict) and order.get('id') == order_id_procured and order.get('status') == 'pending_procurement': - order['is_procured'] = True - order['status'] = 'pending' # Меняем статус на "Ожидает пошива" (готово к раскрою) - order['procurement_timestamp'] = get_current_time().isoformat() - order_updated = True - order_name = order.get('model_name', 'N/A') - break - if order_updated: - with data_lock: - # data['orders'] уже содержит обновленный заказ - save_data(data) - flash(f"Заказ '{order_name}' отмечен как закупленный и готов к раскрою.", "success") - upload_db_to_hf(DATA_FILE) - else: - flash(f"Заказ с ID {order_id_procured} для отметки о закупе не найден или уже обработан.", "warning") - return redirect(url_for('procurement')) - - # --- Обработка добавления/обновления материалов --- - 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', []) # Получаем текущий сп��сок материалов - current_categories = data.get('categories', []) # Получаем текущие категории - - 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] - material_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}: Количество должно быть > 0.", "danger"); continue - if price < 0: flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger"); continue - - # Обработка "Расход на ед." (material_per_unit) - может быть дробным - material_per_unit = Decimal('0') - if material_per_unit_str: - try: material_per_unit = to_decimal(material_per_unit_str) - except InvalidOperation: flash(f"Предупреждение в строке {i+1}: Некорректный расход на ед., установлено 0.", "warning"); material_per_unit = Decimal('0') - if material_per_unit < 0: material_per_unit = Decimal('0') - - # Определение категории - final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории") - # Добавляем новую категорию, если её нет - current_valid_categories = [c for c in current_categories if isinstance(c, str)] - if new_category and final_category not in current_valid_categories: - current_valid_categories.append(final_category) - current_categories = current_valid_categories # Обновляем текущий список - - # Поиск существующего материала (по названию, типу и категории) - existing_material_index = -1 - for idx, mat in enumerate(current_materials): - 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 - existing_material['material_per_unit'] = str(material_per_unit) # Обновляем расход - existing_material['timestamp_last_updated'] = procurement_time - logging.info(f"Материал '{name}' обновлен. Новое количество: {new_quantity}, Цена: {price}, Категория: {final_category}, Расход/ед: {material_per_unit}") - valid_items_processed += 1 - else: - # --- Добавляем новый материал --- - new_material = { - 'id': uuid.uuid4().hex, - 'name': name, - 'quantity': str(quantity), - 'unit': unit, - 'price_per_unit': str(price), - 'material_per_unit': str(material_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}, Расход/ед: {material_per_unit}") - valid_items_processed += 1 - - # Сохраняем данные, если что-то обработали - if valid_items_processed > 0 : - with data_lock: - if materials_to_add: - data['materials'].extend(materials_to_add) - # data['materials'] уже содержит обновленные - data['categories'] = sorted(list(set(c for c in current_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_raw in data.get('materials', []): - if isinstance(m_raw, dict) and 'id' in m_raw: - m_data = find_item_by_id(m_raw['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')) - m_data['price_str'] = format_currency_py(m_data.get('price_per_unit', '0.00')) - m_data['material_per_unit_str'] = format_currency_py(m_data.get('material_per_unit', '0.00')) # Форматируем расход - materials_display.append(m_data) - materials_display.sort(key=lambda x: x.get('name', '').lower()) # Сортируем по имени - - valid_categories = sorted([c for c in categories if isinstance(c, str)], key=str.lower) - - html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) - return render_template_string(html, categories=valid_categories, materials_display=materials_display, orders=pending_orders) - -# 2. Маршрут "Раскрой" -@app.route('/cutting', methods=['GET', 'POST']) -def cutting(): - data = load_data() - # Фильтруем ткани с положительным количеством - fabrics = [] - for m_raw in data.get('materials', []): - if isinstance(m_raw, dict) and m_raw.get('type') == 'fabric': - m_data = find_item_by_id(m_raw['id'], 'materials') - if m_data and m_data.get('quantity', Decimal('0')) > 0: - m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) # Форматируем для отображения - fabrics.append(m_data) - fabrics.sort(key=lambda x: x.get('name', '').lower()) # Сортируем - - 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 all([fabric_id, cut_items_quantity_str, fabric_used_str]): - flash("Выберите ткань и заполните все поля.", "danger") - return redirect(url_for('cutting')) - - fabric_material = find_item_by_id(fabric_id, 'materials') - if not fabric_material: - 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')) - - available_quantity = fabric_material.get('quantity', Decimal('0.00')) - if fabric_used > available_quantity: - flash(f"Недостаточно ткани '{fabric_material['name']}'. В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger") - return redirect(url_for('cutting')) - - 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), # str - 'status': 'pending', - 'timestamp_created': creation_time, - 'timestamp_completed': None, - 'material_cost': str(material_cost), # str - 'cutting_salary_cost': str(cutting_salary_cost) # str - } - - 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'))) - current_materials[i]['timestamp_last_updated'] = creation_time - material_updated = True - break - - if not material_updated: - flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger"); return redirect(url_for('cutting')) - - with data_lock: - if 'cutting_tasks' not in data or not isinstance(data['cutting_tasks'], list): 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 запрос - html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Раскрой ткани").replace('__CONTENT__', CUTTING_CONTENT).replace('__SCRIPTS__', CUTTING_SCRIPTS) - return render_template_string(html, fabrics=fabrics) - -# 3. Маршрут "Пошив" -@app.route('/sewing', methods=['GET', 'POST']) -def sewing(): - data = load_data() - # Задания раскроя, ожидающие пошива - pending_cutting_tasks_raw = data.get('cutting_tasks', []) - pending_cutting_tasks = [] - for task_raw in pending_cutting_tasks_raw: - if isinstance(task_raw, dict) and task_raw.get('status') == 'pending': - task_data = find_item_by_id(task_raw['id'], 'cutting_tasks') - if task_data: - task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00')) - pending_cutting_tasks.append(task_data) - pending_cutting_tasks.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) - - # Доступная фурнитура - available_fittings = [] - for m_raw in data.get('materials', []): - if isinstance(m_raw, dict) and m_raw.get('type') == 'fittings': - m_data = find_item_by_id(m_raw['id'], 'materials') - if m_data and int(to_decimal(m_data.get('quantity', '0'))) > 0: - m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) # Форматируем для отображения - available_fittings.append(m_data) - available_fittings.sort(key=lambda x: x.get('name', '').lower()) - - # Все материалы (для выбора брака) - all_materials = [] - for m_raw in data.get('materials', []): - if isinstance(m_raw, dict) and 'id' in m_raw: - m_data = find_item_by_id(m_raw['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.append(m_data) - all_materials.sort(key=lambda x: x.get('name', '').lower()) - - 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 all([cutting_task_id, sewn_product_name, 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()) - 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 (Decimal)} - 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()); assert quantity_used > 0 - except: flash(f"Некорректное кол-во фурнитуры '{fitting_material['name']}'.", "danger"); is_valid = False; break - - available_qty_int = int(fitting_material.get('quantity', Decimal('0'))) - planned_deduction_int = int(materials_to_update.get(fitting_id, Decimal('0'))) - if available_qty_int < planned_deduction_int + quantity_used: - flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. В наличии: {format_integer_py(available_qty_int)}, нужно: {format_integer_py(quantity_used)} (с учетом др. строк).", "danger"); is_valid = False; break - - 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')) - 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, '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; is_fabric = material_type == 'fabric' - try: - if is_fabric: quantity_deduct = to_decimal(quantity_str); assert quantity_deduct > 0; quantity_log_value = quantity_deduct - else: quantity_int = int(to_decimal(quantity_str).to_integral_value()); assert quantity_int > 0; quantity_deduct = Decimal(quantity_int); quantity_log_value = quantity_int - except: flash(f"Некорректное кол-во брака для '{defect_material['name']}'.", "warning"); continue - - available_qty = defect_material.get('quantity', Decimal('0')); planned_deduction = materials_to_update.get(material_id, Decimal('0')); effective_available = available_qty - planned_deduction - if effective_available < quantity_deduct: - avail_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']}). Доступно: {avail_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')); 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), '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')) - - # --- Списание материалов (фурнитура + брак) --- - with data_lock: # Блокируем перед списанием и сохранением - current_materials = data.get('materials', []) - all_updates_successful = True - 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: new_qty = new_qty.quantize(Decimal('0'), rounding=ROUND_HALF_UP) - 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 - logging.info(f"Списан материал ID {material_id}: {format_currency_py(quantity_to_deduct) if mat.get('type') == 'fabric' else format_integer_py(quantity_to_deduct)} {mat.get('unit')}. Новый остаток: {format_currency_py(new_qty) if mat.get('type') == 'fabric' else format_integer_py(new_qty)}") - break - if not material_updated: - flash(f"Критическая ошибка: Не удалось списать материал ID {material_id}.", "danger"); all_updates_successful = False; break - - if not all_updates_successful: 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, 'fabric_id': cutting_task['fabric_id'], 'fabric_name': cutting_task['fabric_name'], 'fittings_consumed': fittings_consumed, '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), '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; break - elif isinstance(task, dict) and task.get('id') == cutting_task_id: - cutting_task_updated = True; logging.warning(f"Попытка обновить статус уже обработанного задания раскроя {cutting_task_id}."); break - if not cutting_task_updated: logging.error(f"Критическая ошибка: Не удалось обновить статус задания раскроя {cutting_task_id}.") # Продолжаем, но логируем - - # --- Сохранение всех изменений --- - 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']) - - save_data(data) # Сохраняем все в рамках одной блокировки - - flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' зарегистрирован. Статус: Ожидает ОТК.", "success") - if fittings_consumed: flash(f"Использовано {len(fittings_consumed)} поз. фурнитуры на {format_currency_py(fittings_total_cost)} сом.", "info") - 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 запрос - html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Пошив изделий").replace('__CONTENT__', SEWING_CONTENT).replace('__SCRIPTS__', SEWING_SCRIPTS) - return render_template_string(html, cutting_tasks=pending_cutting_tasks, fittings=available_fittings, all_materials=all_materials) - -# 4. Маршрут "ОТК и Упаковка" -@app.route('/qc_packing', methods=['GET', 'POST']) -def qc_packing(): - data = load_data() - # Задания пошива, ожидающие ОТК - pending_qc_tasks_raw = data.get('sewing_tasks', []) - pending_qc_tasks = [] - for task_raw in pending_qc_tasks_raw: - if isinstance(task_raw, dict) and task_raw.get('status') == 'pending_qc': - task_data = find_item_by_id(task_raw['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 - pending_qc_tasks.append(task_data) - pending_qc_tasks.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) - - 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') - 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("Количество >= 0") - total_processed_now = quantity_packed + quantity_defective - if total_processed_now <= 0: flash("Укажите кол-во упакованных или брака (>0).", "warning"); return redirect(url_for('qc_packing')) - - 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"Ошибка: Сумма ({total_processed_now}) > остатка ({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 - new_defect_log_entry = None - - with data_lock: # Блокируем перед расчетами, созданием записей и сохранением - # --- Обработка упакованных --- - if quantity_packed > 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} - - 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) - 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 - 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, '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 or not isinstance(data['qc_packing_items'], list): 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} - - 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 - 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, '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 or not isinstance(data['defect_log'], list): 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_packed = int(task.get('qc_packed_quantity', 0)) + quantity_packed - current_defective = int(task.get('qc_defective_quantity', 0)) + quantity_defective - current_sewing_tasks[i]['qc_packed_quantity'] = current_packed - current_sewing_tasks[i]['qc_defective_quantity'] = current_defective - total_processed_for_task = current_packed + current_defective - task_sewn_qty = int(task.get('sewn_quantity', 0)) - if total_processed_for_task >= task_sewn_qty: - current_sewing_tasks[i]['status'] = 'completed'; current_sewing_tasks[i]['timestamp_completed'] = qc_time - logging.info(f"Задание пошива {sewing_task_id} завершено.") - else: - current_sewing_tasks[i]['status'] = 'pending_qc' - logging.info(f"Задание пошива {sewing_task_id} обработано частично. Осталось: {task_sewn_qty - total_processed_for_task}") - sewing_task_updated = True; break - if not sewing_task_updated: logging.error(f"Критическая ошибка: Не удалось обновить задание пошива {sewing_task_id}.") # Продолжаем, но логируем - - # --- Сохранение --- - 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 запрос - html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS) - return render_template_string(html, sewing_tasks=pending_qc_tasks) - -# 5. Маршрут "База клиентов" -@app.route('/clients', methods=['GET', 'POST']) -def clients_panel(): - if request.method == 'POST': - action = request.form.get('action', 'add') - clients = load_client_data() - - if action == 'edit': - client_id = request.form.get('client_id') - 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 or not client_id: - flash("Заполните обязательные поля.", "danger"); return redirect(url_for('clients_panel')) - - client_found = False; normalized_phone = ''.join(filter(str.isdigit, phone)) - with client_data_lock: - for client in clients: - if client.get('id') == client_id: - current_phone = ''.join(filter(str.isdigit, client.get('phone', ''))) - if normalized_phone != current_phone: - if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone for c in clients if c.get('id') != client_id): - flash(f"Телефон {phone} уже используется другим клиентом.", "warning"); return redirect(url_for('clients_panel')) - client['name'] = name; client['phone'] = phone; client['address'] = address if address else None - client_found = True; break - if client_found: save_client_data(clients); flash(f"Данные клиента '{name}' обновлены.", "success"); upload_db_to_hf(CLIENT_DATA_FILE) - else: flash("Клиент не найден.", "danger") - return redirect(url_for('clients_panel')) - - elif action == 'delete': - client_id = request.form.get('client_id') - if not client_id: flash("ID клиента не указан.", "danger"); return redirect(url_for('clients_panel')) - - client_found = False; deleted_name = 'Неизвестный клиент'; new_clients_list = [] - with client_data_lock: - for client in clients: - if client.get('id') == client_id: - if client.get('history', []): - flash("Нельзя удалить клиента с историей отправок.", "warning"); new_clients_list = clients; client_found=False; break # Прерываем и не удаляем - else: - deleted_name = client.get('name', 'Неизвестный клиент'); client_found = True # Помечаем для удаления - else: - new_clients_list.append(client) # Добавляем остальных - if client_found: save_client_data(new_clients_list); flash(f"Клиент '{deleted_name}' удален.", "success"); upload_db_to_hf(CLIENT_DATA_FILE) - elif not any(cl.get('id') == client_id for cl in clients if isinstance(cl,dict)): # Если не нашли вообще - flash("Клиент не найден.", "danger") - # Если нашли, но не удалили из-за истории, флеш уже был выставлен - return redirect(url_for('clients_panel')) - - else: # Добавление - 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')) - normalized_phone = ''.join(filter(str.isdigit, phone)) - with client_data_lock: - 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, '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()) - for client in clients_data: - if 'history' in client: - client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True) - for record in client['history']: record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) - 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', {}) - - # Получаем актуальные данные - all_materials_raw = data.get('materials', []) - all_materials = [m for m_raw in all_materials_raw if isinstance(m_raw, dict) and 'id' in m_raw and (m := find_item_by_id(m_raw['id'], 'materials')) is not None and m.get('quantity', Decimal('0')) > 0] - materials_count = len(all_materials) - - all_cutting_tasks_raw = data.get('cutting_tasks', []) - all_cutting_tasks = [t for t_raw in all_cutting_tasks_raw if isinstance(t_raw, dict) and 'id' in t_raw and (t := find_item_by_id(t_raw['id'], 'cutting_tasks')) is not None] - - all_sewing_tasks_raw = data.get('sewing_tasks', []) - all_sewing_tasks = [s for s_raw in all_sewing_tasks_raw if isinstance(s_raw, dict) and 'id' in s_raw and (s := find_item_by_id(s_raw['id'], 'sewing_tasks')) is not None] - - all_packed_items_raw = data.get('qc_packing_items', []) - all_packed_items = [p for p_raw in all_packed_items_raw if isinstance(p_raw, dict) and 'id' in p_raw and (p := find_item_by_id(p_raw['id'], 'qc_packing_items')) is not None] - - all_defect_log_raw = data.get('defect_log', []) - all_defect_log = [d for d_raw in all_defect_log_raw if isinstance(d_raw, dict) and 'log_id' in d_raw and (d := find_item_by_id(d_raw['log_id'], 'defect_log')) is not None] - - all_expenses_raw = data.get('expenses', []) - all_expenses = [e for e_raw in all_expenses_raw if isinstance(e_raw, dict) and 'id' in e_raw and (e := find_item_by_id(e_raw['id'], 'expenses')) is not None] - - dordoi_shipments_raw = data.get('dordoi_shipments', []) - dordoi_shipments = [] - for ship_raw in dordoi_shipments_raw: - if isinstance(ship_raw, dict) and 'shipment_id' in ship_raw: - ship_raw['timestamp_dt'] = parse_iso_datetime(ship_raw.get('timestamp')) # Парсим дату здесь - dordoi_shipments.append(ship_raw) - 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) - total_packed_count = items_ready_ship_qty # "Упаковано Всего" = только готовые к отправке - - pending_cutting_count = len([task for task in all_cutting_tasks if task.get('status') == 'pending']) - pending_qc_tasks_list = [task for task in all_sewing_tasks if task.get('status') == 'pending_qc'] - pending_qc_count = len(pending_qc_tasks_list) - pending_qc_quantity = sum(max(0, task.get('sewn_quantity', 0) - (task.get('qc_packed_quantity', 0) + task.get('qc_defective_quantity', 0))) for task in pending_qc_tasks_list) - - 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 - quantity_to_dispatch_str = request.form.get('quantity_to_dispatch') - redirect_target = url_for('admin_panel') + '#dispatch-content' - - if not all([item_id, destination_type, quantity_to_dispatch_str]): - flash("Ошибка: Не все поля для отправки заполнены.", "danger"); return redirect(redirect_target) - - data = load_data() - clients = load_client_data() - - packed_item_to_update = None; item_index = -1 - packed_items_list = data.get('qc_packing_items', []) - - for i, item in enumerate(packed_items_list): - if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': - packed_item_to_update = item; item_index = i; break - if not packed_item_to_update: - flash(f"Ошибка: Товар {item_id}, готовый к отправке, не найден.", "danger"); return redirect(redirect_target) - - try: - quantity_to_dispatch = int(to_decimal(quantity_to_dispatch_str).to_integral_value()) - current_quantity = int(to_decimal(packed_item_to_update.get('quantity', '0'))) - if quantity_to_dispatch <= 0: raise ValueError("Количество > 0") - if quantity_to_dispatch > current_quantity: flash(f"Ошибка: Нельзя отправить {quantity_to_dispatch} шт., в наличии {current_quantity} шт.", "danger"); return redirect(redirect_target) - except (InvalidOperation, ValueError) as e: - flash(f"Некорректное количество для отправки: {e}", "danger"); return redirect(redirect_target) - - dispatch_time_iso = get_current_time().isoformat() - client_data_changed = False; main_data_changed = False - product_name = packed_item_to_update.get('product_name', 'N/A') - destination_display_text = '' - is_full_dispatch = (quantity_to_dispatch == current_quantity) - history_items = [{'product_name': product_name, 'quantity': quantity_to_dispatch}] - - # Блокируем оба файла перед модификацией - with data_lock, client_data_lock: - packed_items_list = data.get('qc_packing_items', []) # Перезагружаем внутри лока - packed_item_to_update = None; item_index = -1 - for i, item in enumerate(packed_items_list): - if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': - packed_item_to_update = item; item_index = i; break - if not packed_item_to_update: # Повторная проверка внутри лока - flash(f"Ошибка: Товар {item_id}, готовый к отправке, не найден (возможно, уже отправлен).", "danger"); return redirect(redirect_target) - current_quantity = int(to_decimal(packed_item_to_update.get('quantity', '0'))) # Перезагружаем кол-во - if quantity_to_dispatch > current_quantity: # Повторная проверка кол-ва - flash(f"Ошибка: Нельзя отправить {quantity_to_dispatch} шт., в наличии {current_quantity} шт. (возможно, изменилось).", "danger"); return redirect(redirect_target) - - if destination_type == 'client': - if not client_id: flash("Ошибка: Не выбран клиент.", "danger"); return redirect(redirect_target) - client_object_to_update = None; client_name = "Клиент не найден" - clients = load_client_data() # Перезагружаем клиентов внутри лока - for cl in clients: - if cl.get('id') == client_id: client_object_to_update = cl; client_name = cl.get('name', 'Имя не найдено'); break - if not client_object_to_update: flash(f"Ошибка: Клиент {client_id} не найден.", "danger"); return redirect(redirect_target) - - history_entry = {'shipment_id': uuid.uuid4().hex, 'timestamp': dispatch_time_iso, 'items': history_items, 'packed_item_id': item_id} - if not isinstance(client_object_to_update.get('history'), list): client_object_to_update['history'] = [] - client_object_to_update['history'].append(history_entry); client_data_changed = True - destination_display_text = f"клиенту '{client_name}'" - - if is_full_dispatch: - packed_item_to_update['status'] = 'shipped_client' - packed_item_to_update['shipment_details'] = {'type': destination_type, 'timestamp': dispatch_time_iso, 'client_id': client_id, 'client_name': client_name} - main_data_changed = True - - elif destination_type == 'dor_doi_point': - dordoi_entry = {'shipment_id': uuid.uuid4().hex, 'timestamp': dispatch_time_iso, 'items': history_items, '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); main_data_changed = True - destination_display_text = "на Торговую точку Дордой" - - if is_full_dispatch: - packed_item_to_update['status'] = 'shipped_dor_doi' - packed_item_to_update['shipment_details'] = {'type': destination_type, 'timestamp': dispatch_time_iso, 'destination': 'Торговая точка Дордой'} - # main_data_changed уже True - - else: flash("Ошибка: Неверный тип назначения.", "danger"); return redirect(redirect_target) - - # Обработка частичной отправки - if not is_full_dispatch: - remaining_quantity = current_quantity - quantity_to_dispatch - proportion_remaining = Decimal(remaining_quantity) / Decimal(current_quantity) - fields_to_recalculate = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'] - for field in fields_to_recalculate: - current_cost = to_decimal(packed_item_to_update.get(field, '0')) - remaining_cost = (current_cost * proportion_remaining).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - packed_item_to_update[field] = str(remaining_cost) - packed_item_to_update['quantity'] = remaining_quantity # Обновляем количество - main_data_changed = True - logging.info(f"Частичная отправка {item_id} ({product_name}): {quantity_to_dispatch} шт. Осталось: {remaining_quantity} шт.") - - # Сохранение - if main_data_changed: save_data(data); logging.info(f"Основные данные сохранены после отправки {item_id}.") - if client_data_changed: save_client_data(clients); logging.info(f"Данные клиента {client_id} сохранены.") - - # Бэкап после снятия локов - if main_data_changed: upload_db_to_hf(DATA_FILE) - if client_data_changed: upload_db_to_hf(CLIENT_DATA_FILE) - - flash(f"{quantity_to_dispatch} шт. '{product_name}' успешно отправлено {destination_display_text}.", "success") - return redirect(redirect_target) - -# --- Маршрут для работы с авансами --- -@app.route('/advances', methods=['GET', 'POST']) -def advances(): - data = load_data() - if 'advances' not in data or not isinstance(data['advances'], list): - data['advances'] = [] - # 'monthly_salaries' сейчас не используется напрямую на этой странице, но инициализируется в load_data/initialize_data_structure - - if request.method == 'POST': - try: - employee_name = request.form.get('employee_name', '').strip() - role = request.form.get('role', '').strip() - amount_str = request.form.get('amount', '0') - - if not employee_name or not role or not amount_str: - flash("Заполните все поля", "danger"); return redirect(url_for('advances')) - - amount = to_decimal(amount_str) - if amount <= 0: flash("Сумма аванса должна быть > 0", "danger"); return redirect(url_for('advances')) - - advance = { 'id': uuid.uuid4().hex, 'employee_name': employee_name, 'role': role, 'amount': str(amount), 'timestamp': get_current_time().isoformat(), 'is_processed': False } - - with data_lock: - data['advances'].append(advance) - save_data(data) - flash(f"Аванс {format_currency_py(amount)} сом выдан {employee_name}", "success") - upload_db_to_hf(DATA_FILE) - - except Exception as e: - logging.error(f"Ошибка при выдаче аванса: {e}", exc_info=True) - flash(f"Ошибка при выдаче аванса: {e}", "danger") - return redirect(url_for('advances')) # Перенаправляем в любом случае после POST - - # GET запрос - advances_raw = data.get('advances', []) - advances_list = [] - for adv_raw in advances_raw: - if isinstance(adv_raw, dict) and 'id' in adv_raw: - adv_data = find_item_by_id(adv_raw['id'], 'advances') # Используем find_item для преобразования amount - if adv_data: advances_list.append(adv_data) - advances_list.sort(key=lambda x: x.get('timestamp', ''), reverse=True) - - html = BASE_TEMPLATE.replace('__TITLE__', "Авансы").replace('__CONTENT__', ADVANCES_CONTENT).replace('__SCRIPTS__', ADVANCES_SCRIPTS) - return render_template_string(html, advances=advances_list) - -# --- НОВОЕ: Маршрут удаления аванса --- -@app.route('/advances/delete/', methods=['POST']) -def delete_advance(advance_id): - data = load_data() - advances_list = data.get('advances', []) - advance_found = False - deleted_info = "" - - new_advances_list = [] - for adv in advances_list: - if isinstance(adv, dict) and adv.get('id') == advance_id: - # Проверяем, был ли аванс уже учтен (если нужно запретить удаление учтенных) - # if adv.get('is_processed'): - # flash("Нельзя удалить уже учтенный аванс.", "warning") - # new_advances_list.append(adv) # Оставляем - # else: - # advance_found = True - # deleted_info = f"для {adv.get('employee_name', '?')} на {format_currency_py(adv.get('amount','0'))} сом" - # Пока разрешаем удалять любой аванс - advance_found = True - deleted_info = f"для {adv.get('employee_name', '?')} на {format_currency_py(adv.get('amount','0'))} сом" - else: - new_advances_list.append(adv) - - if advance_found: - with data_lock: - data['advances'] = new_advances_list - save_data(data) - flash(f"Аванс {deleted_info} успешно удален.", "success") - upload_db_to_hf(DATA_FILE) - else: - # Если не нашли или не удалили из-за is_processed - if not any(a.get('id') == advance_id for a in advances_list if isinstance(a, dict)): - flash("Аванс не найден.", "warning") - - return redirect(url_for('advances')) - -# --- Остальные маршруты админ-панели --- -@app.route('/admin/config/update', methods=['POST']) -def update_config(): - data = load_data() - config = data.get('config', {}) - try: - 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'))) - with data_lock: - 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"Ошибка обновления config: {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') - redirect_target = url_for('admin_panel') + '#expenses-report-content' - - if not description or not amount_str: flash("Заполните описание и сумму.", "warning"); return redirect(redirect_target) - try: amount = to_decimal(amount_str); assert amount > 0 - except: flash("Некорректная сумма расхода (>0).", "warning"); return redirect(redirect_target) - - 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() } - - with data_lock: - data['expenses'].append(new_expense) - save_data(data) - flash(f"Расход '{description}' на {format_currency_py(amount)} сом добавлен.", "success") - upload_db_to_hf(DATA_FILE) - return redirect(redirect_target) - -# --- НОВОЕ: Маршрут удаления доп. расхода --- -@app.route('/admin/expense/delete/', methods=['POST']) -def delete_expense(expense_id): - data = load_data() - expenses_list = data.get('expenses', []) - expense_found = False - deleted_info = "" - - new_expenses_list = [] - for exp in expenses_list: - if isinstance(exp, dict) and exp.get('id') == expense_id: - expense_found = True - deleted_info = f"'{exp.get('description', '?')}' на {format_currency_py(exp.get('amount','0'))} сом" - else: - new_expenses_list.append(exp) - - if expense_found: - with data_lock: - data['expenses'] = new_expenses_list - save_data(data) - flash(f"Расход {deleted_info} успешно удален.", "success") - upload_db_to_hf(DATA_FILE) - else: - flash("Расход не найден.", "warning") - - 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) - with data_lock: - 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) - materials_updated_count = 0; update_time = get_current_time().isoformat() - with data_lock: - data['categories'] = sorted(current_valid_categories, key=str.lower) - current_materials = data.get('materials', []) - 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 - 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(): - 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} файлов инициирован.", "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(): - 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}' не найден."; logging.error(msg); errors.append(msg) - except HfHubHTTPError as e: - if e.response.status_code == 404: msg = f"Файл '{DATA_FILE}' не найден в репозитории."; 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}' не найден."; logging.error(msg); errors.append(msg) - except HfHubHTTPError as e: - if e.response.status_code == 404: msg = f"Файл '{CLIENT_DATA_FILE}' не найден в репозитории."; 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() - - 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) - - filtered_defects = []; all_defect_log_raw = data.get('defect_log', []) - 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) - - filtered_expenses = []; all_expenses_raw = data.get('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) - 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: - name = item.get('product_name', 'Неизвестный'); qty = item.get('quantity', 0); rev = item.get('packed_final_price', Decimal('0')); cost = item.get('packed_total_cost', Decimal('0')); prof = rev - cost - if name not in production_summary: production_summary[name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')} - production_summary[name]['quantity'] += qty; production_summary[name]['revenue'] += rev; production_summary[name]['cost'] += cost; production_summary[name]['profit'] += prof - - 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) - -# 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) - file_meta = { 'file_id': uuid.uuid4().hex, 'original_filename': original_filename, 'stored_filename': stored_filename, 'thumbnail_filename': thumbnail_filename, 'description': description, 'timestamp': get_current_time().isoformat(), 'size': os.path.getsize(file_path) } - with data_lock: - 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() - filtered_files = [ f for f in cloud_files if isinstance(f, dict) and (search_query in f.get('description', '').lower() or search_query in f.get('original_filename', '').lower()) ] if search_query else [f for f in cloud_files if isinstance(f, dict)] - 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): - try: 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: return "Thumbnail not found", 404 - except Exception as e: logging.error(f"Ошибка отдачи миниатюры {filename}: {e}"); return "Error getting thumbnail", 500 - -# 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 isinstance(f, dict) and 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 - deleted_filename = file_to_delete.get('original_filename', 'N/A') - try: - with data_lock: - 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"Файл '{deleted_filename}' удален.", 'success'); upload_db_to_hf(DATA_FILE) - except Exception as e: - logging.error(f"Ошибка удаления файла {file_id}: {e}", exc_info=True); flash(f"Ошибка удаления: {e}", 'danger') - else: flash('Файл для удаления не найден.', 'warning') - return redirect(url_for('cloud_storage')) - - -# --- HTML Шаблоны --- - -# Контент заказов -ORDERS_CONTENT = """ -
-
Создание заказа
-
-
- {# Клиент и Модель #} -
-
- - -
-
- - -
-
- - {# Ткань и Количество #} -
-
- - -
-
- - -
-
- - -
-
- - {# Размерный ряд и Предоплата #} -
-
- - -
-
- - -
-
- - {# Фурнитура #} -
-
Фурнитура (необходимая для заказа):
-
- -
-
- -
-
- -
-
- -
-
- -
- -
- -
- -
-
-
- -
-
Список заказов
-
-
- - - - - - - - - - - - - - - - - - - {% for order in orders %} {# Цикл по отсортированному списку #} - - - - - - {# Используем format_currency_py #} - - {# Используем format_integer_py #} - - {# Используем format_currency_py #} - - - - - {% else %} - - {# Обновлен colspan #} - - {% endfor %} - -
IDКлиентМодельТканьКол-во тканиФурнитураКол-во изделийРазмерный рядПредоплатаСтатусСозданДействия
{{ order.id[:8] }}...{{ order.client_name }}{{ order.model_name }}{{ order.fabric_name }}{{ format_currency_py(order.fabric_quantity) }} - {% if order.fittings %} -
    - {% for f in order.fittings %} -
  • {{ f.fitting_name }}: {{ format_integer_py(f.quantity) }}
  • {# Используем format_integer_py #} - {% endfor %} -
- {% else %}-{% endif %} -
{{ format_integer_py(order.items_quantity) }}{{ order.size_range | default('-') }}{{ format_currency_py(order.prepayment) }}{{ getStatusText(order.status) }}{{ order.timestamp_created[:16]|replace('T', ' ') }} - {# Кнопка редактирования (если будет реализована) #} - {# #} - - {# Кнопка удаления #} -
- -
-
Заказы отсутствуют.
-
-
-
-""" - -# Скрипты заказов -ORDERS_SCRIPTS = """ - -""" - -# ОБЫЧНЫЙ Базовый шаблон (с навигацией) -BASE_TEMPLATE = """ - - - - - - __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') }})

-
-
- - - - - - -__SCRIPTS__ - - -""" - -# ОПЕРАЦИОННЫЙ Базовый шаблон (БЕЗ навигации и БЕЗ кнопки "Назад") -BASE_TEMPLATE_OPERATIONAL = """ - - - - - - __TITLE__ - КШП - - - - - - - -
-

__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') }})

-
-
- - - - - - -__SCRIPTS__ - - -""" - -# Контент закупки -PROCUREMENT_CONTENT = """ - -
-
Заказы, ожидающие закупа
-
-
- - - - - - - - - - - - - - - - - {% for order in orders %} {# Используем уже отфильтрованный список #} - - - - - - - - - - - - - {% else %} - - - - {% endfor %} - -
ID ЗаказаКлиентИзделиеТканьКол-во тканиКол-во изделийРазмерный рядФурнитураСозданДействия
{{ order.id[:8] }}...{{ order.client_name }}{{ order.model_name }}{{ order.fabric_name }}{{ format_currency_py(order.fabric_quantity) }}{{ format_integer_py(order.items_quantity) }}{{ order.size_range | default('-') }} - {% if order.fittings %} -
    - {% for f in order.fittings %} -
  • {{ f.fitting_name }}: {{ format_integer_py(f.quantity) }}
  • - {% endfor %} -
- {% else %}-{% endif %} -
{{ order.timestamp_created[:16]|replace('T', ' ') }} -
- - -
-
Нет заказов, ожидающих закуп.
-
-
-
- - -
-
Добавить закупленные материалы
-
-
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - -
-
- - -
-
- - - -
- -
- -
- - -
-
-
- - -
-
Материалы на складе (Справка)
-
- -
- - - - - - - - - - - - - - - {% for m in materials_display %} {# Используем materials_display #} - - - - - - - - {# Используем отформатированный расход #} - - - {% else %} - - {% endfor %} - -
НазваниеКатегорияТипКол-воЕд.изм.Цена/едРасход/едОбновлен
{{ m.name }}{{ m.category | default('Без категории') }}{{ 'Ткань' if m.type == 'fabric' else 'Фурнитура' }}{{ m.quantity_str }}{{ m.unit }}{{ m.price_str }}{{ m.material_per_unit_str }}{{ m.timestamp_last_updated[:10] if m.timestamp_last_updated else 'N/A' }}
Нет материалов на складе.
-
-
-
-""" - -# Скрипты закупки -PROCUREMENT_SCRIPTS = """ - -""" - -# Контент раскроя -CUTTING_CONTENT = """ -
-
Регистрация раскроя
-
-
-
-
- - -
-
{# Изменено ID #} -
-
- - {# Добавлен oninput #} -
-
- - - {# Для вывода расчета #} -
-
- -
-
-
-
-
-""" - -# Скрипты раскроя -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 = """ -
-

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

-
- - -
-
Добавить нового клиента
-
-
- {# Явно указываем действие #} -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- - -
-
Список клиентов
-
-
-
- - - - - - - - - - - - {% for client in clients %} - - - - - - - - {% else %} {# Обновлен colspan #} - {% endfor %} - - -
ID Имя / Организация Телефон Адрес Действия
{{ client.id[:8] }}...{{ client.name }}{{ client.phone }}{{ client.address | default('-') | safe }} -
- - - {% if not client.history %} {# Разрешаем удалять только клиентов БЕЗ истории #} -
- - - -
- {% else %} {# Если есть история, кнопку можно задизейблить или скрыть #} - - {% endif %} -
-
Клиенты не добавлены.
-
-
-
- - - - - -{% for client in clients %} - -{% endfor %} -""" - - -# Скрипты Базы Клиентов -CLIENTS_SCRIPTS = """ - -""" - -# Контент Админ-панели -ADMIN_CONTENT = """ -

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

-

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

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

{{ materials_count }}

позиций на складе (>0)
-
Ожидают пошива

{{ 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 %} -
-
- - -
-
Дополнительные расходы
-
- {# Форма добавления #} -
-
-
-
-
-
- {# Журнал #} -
Журнал доп. расходов:
-
- - {# Добавлены Действия #} - - {% for expense in expenses|sort(attribute='timestamp', reverse=True) %} - - - - - - - - {% else %} - {# Обновлен colspan #} - {% endfor %} - -
IDОписаниеСумма (сом)ДатаДействия
{{ expense.id[:8] }}...{{ expense.description }}{{ format_currency_py(expense.amount) }}{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }} {# Кнопка удаления #} -
- {# Используем btn-xs для маленькой кнопки #} -
-
Доп. расходы не добавлялись.
-
-
-
- - - - - -
- -
-
Список материалов (остаток > 0)
- -
- - - - - - - - {# Изменено с На ед. #} - - - - {% for m in materials|sort(attribute='name') %} {# materials уже отфильтрованы в Python #} - - - - - - - {# Используем material_per_unit #} - {# Убрано время #} - {# Убрано время #} - - {% else %} - - {% endfor %} -
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_currency_py(m.material_per_unit) }}{{ m.timestamp_added[:10] if m.timestamp_added else 'N/A' }}{{ m.timestamp_last_updated[:10] if m.timestamp_last_updated else 'N/A' }}
Нет материалов в наличии.
-
- -
-
Задания на раскрой
-
- - - {% 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 '-' }}
Нет заданий на раскрой.
-
- -
-
Задания на пошив
-
- - - - - {% 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) }} - {% set total_fitting_cost = to_decimal(task.fittings_cost) %} - {% if total_fitting_cost > 0 %} - {{ format_currency_py(total_fitting_cost) }} сом - {% else %}-{% endif %} - - {% if task.defects_reported is iterable and task.defects_reported is not string and task.defects_reported|length > 0 %} - {{ task.defects_reported|length }} поз. - {% 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] }}...
Нет заданий на пошив.
-
- -
-
Упакованные изделия (все)
-
- - - {% for item in 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) }}{{ 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.get('timestamp')[:10] 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 %} -
- - - {% 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 %} {# dordoi_shipments уже отсортирован #} - - - - - - - {% 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] if shipment.packed_item_id else 'N/A' }}...
- {% else %} - - {% endif %} -
-
- -
-
Журнал брака
-
- - - - {% for defect in defect_log|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' }}...
Записи о браке отсутствуют.
-
-
-""" - -# Скрипты Админ-панели -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 }} зап.)
-
- - - - -
- -
-
Сводка по продуктам за период
-
- - - {% 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) }}
Нет данных.
-
- -
-
Упакованные за период
-
- - - {% 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 %} {{ 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 %}
{% else %}-{% endif %}
{{ item.sewing_task_id[:8] }}...
Нет упакованных за период.
-
- -
-
Брак за период
-
- {% 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' }}...
Нет брака за период.
-
- -
-
Доп. расходы за период
-
- {% 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 = """ - -""" - -# Контент Облака -CLOUD_CONTENT = """ -
-

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

-
- -
-
Загрузить файл
-
-
-
-
Допустимые: png, jpg, gif, pdf, txt, doc(x), xls(x). Макс: 16MB.
- -
-
-
- -
-
Список файлов
-
-
- {% if files %} -
- {% for file in files %} -
-
{# Добавлен h-100 #} -
{# Flex column #} -
- {% if file.thumbnail_filename %} - Миниатюра - {% else %} - {# Иконка #} - {% endif %} -
- - {{ file.original_filename }} - -
-
- {% if file.description %} -

{{ file.description }}

{# flex-grow-1 для описания #} - {% else %} -
{# Пустой div для растяжения #} - {% endif %} -
{# Кнопка и инфо внизу #} -

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

-

Загружен: {{ file.timestamp[:10] }}

-
-
- -
-
-
-
-
-
- {% endfor %} - -
- {% else %} - - {% endif %} -
-
-""" - -# Скрипты Облака -CLOUD_SCRIPTS = """ - -""" - -# Контент Авансов -ADVANCES_CONTENT = """ -
-
Выдача аванса
-
-
-
-
-
-
-
- -
-
-
- -
-
История авансов
-
-
- - - - {# Добавлены Действия #} - - - - {% for advance in advances %} - - - - - - - - {% else %} - {# Обновлен colspan #} - {% endfor %} - -
Дата выдачиСотрудникДолжностьСуммаСтатусДействия
{{ advance.timestamp[:16]|replace('T', ' ') }}{{ advance.employee_name }}{% if advance.role == 'cutter' %}Раскройщик{% elif advance.role == 'sewer' %}Швея{% elif advance.role == 'packer' %}Упаковщик{% else %}{{ advance.role }}{% endif %}{{ format_currency_py(advance.amount) }}{% if advance.is_processed %}Учтено{% else %}Ожидает{% endif %} {# Кнопка удаления #} - {# Можно добавить условие, чтобы нельзя было удалять учтенные: {% if not advance.is_processed %} #} -
- -
- {# {% endif %} #} -
История авансов пуста
-
-
-
-""" - -ADVANCES_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, - 'to_decimal': to_decimal # Добавляем to_decimal для использования в шаблоне, если нужно - } - -if __name__ == '__main__': - # Запускаем поток для периодического бэкапа - backup_thread = threading.Thread(target=periodic_backup, daemon=True) - backup_thread.start() - - try: - logging.info("Первоначальная загрузка данных...") - with data_lock: load_data() - with client_data_lock: 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") - # use_reloader=False важно при использовании threading - app.run(debug=True, host='0.0.0.0', port=7860, use_reloader=False) \ No newline at end of file