# Импортируем необходимые библиотеки 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': [], # Добавлено 'config': {'salary_cutter_per_unit': '0.00', 'salary_sewer_per_unit': '0.00', 'salary_packer_per_unit': '0.00', 'margin_per_item': '0.00'} } @functools.lru_cache(maxsize=1) def get_hf_api(): """Возвращает инициализированный объект HfApi.""" if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE": logging.warning("Токен HF_TOKEN_WRITE не установлен. Загрузка на Hugging Face будет недоступна.") return None try: return HfApi() except Exception as e: logging.error(f"Ошибка инициализации HfApi: {e}"); return None def upload_db_to_hf(filepath=DATA_FILE): """Загружает указанный локальный файл данных на Hugging Face.""" api = get_hf_api() if not api: logging.warning(f"HfApi не инициализирован. Загрузка {filepath} на Hugging Face пропущена."); return if not os.path.exists(filepath): logging.warning(f"Локальный файл {filepath} не найден. Загрузка на Hugging Face пропущена."); return try: filename = os.path.basename(filepath) commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') logging.info(f"Начало загрузки файла {filename} на Hugging Face...") # Используем run_as_future=True для асинхронной загрузки, чтобы не блокировать основной поток api.upload_file( path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Автоматическое резервное копирование {filename} {commit_time}", run_as_future=True ) logging.info(f"Загрузка файла {filename} на Hugging Face инициирована.") except RepositoryNotFoundError: logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face.") except Exception as e: logging.error(f"Ошибка при инициации загрузки {filepath} на Hugging Face: {e}") def periodic_backup(): """Периодически вызывает upload_db_to_hf для обоих файлов.""" logging.info("Запуск потока периодического резервного копирования.") while True: backup_interval = 1800 # 30 минут logging.debug(f"Периодический бэкап спит {backup_interval} секунд...") time.sleep(backup_interval) logging.info("Запуск планового резервного копирования...") try: # Блокировка не обязательна, так как upload_db_to_hf читает существующий файл if os.path.exists(DATA_FILE): upload_db_to_hf(DATA_FILE) else: logging.warning(f"Файл {DATA_FILE} не найден для планового бэкапа.") if os.path.exists(CLIENT_DATA_FILE): upload_db_to_hf(CLIENT_DATA_FILE) else: logging.warning(f"Файл {CLIENT_DATA_FILE} не найден для планового бэкапа.") logging.info("Плановое резервное копирование завершено.") except Exception as e: logging.error(f"Ошибка во время планового резервного копирования: {e}", exc_info=True) class DecimalEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, Decimal): return str(obj) return json.JSONEncoder.default(self, obj) def to_decimal(value_str, default='0.00'): """Безопасно преобразует строку в Decimal.""" if value_str is None or value_str == '': return Decimal(default) try: return Decimal(str(value_str).replace(',', '.')) except InvalidOperation: logging.warning(f"Не удалось преобразовать '{value_str}' в Decimal. Возвращено {default}."); return Decimal(default) def parse_iso_datetime(timestamp_str): """Преобразует строку ISO в объект datetime со знанием часового пояса.""" if not timestamp_str: return None try: # Попытка парсинга напрямую try: dt = datetime.fromisoformat(timestamp_str) except ValueError: # Если не получилось, пытаемся убрать миллисекунды (если они есть) if '.' in timestamp_str: timestamp_str = timestamp_str.split('.', 1)[0] dt = datetime.fromisoformat(timestamp_str) # Повторная попытка # Проверка и установка часового пояса if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: # Если зона не указана, считаем UTC и конвертируем в Бишкек return pytz.utc.localize(dt).astimezone(BISHKEK_TZ) else: # Если зона указана, просто конвертируем в Бишкек return dt.astimezone(BISHKEK_TZ) except (ValueError, TypeError) as e: logging.warning(f"Не удалось разобрать дату: '{timestamp_str}'. Ошибка: {e}") return None def find_item_by_id(item_id, item_list_name): """Обобщенная функция для поиска элемента по ID в основном списке данных.""" data = load_data() # Загружаем свежие данные при каждом поиске items = data.get(item_list_name, []) if not isinstance(items, list): # Доп. проверка logging.error(f"Ожидался список для '{item_list_name}', но получен {type(items)}. Возврат None.") return None for item in items: if not isinstance(item, dict): # Пропускаем не-словари в списке logging.warning(f"Обнаружен не-словарь в списке '{item_list_name}': {item}. Пропущен.") continue # Проверяем как 'id', так и 'log_id' (для defect_log), так и 'file_id' (для cloud_files) current_item_id = item.get('id') or item.get('log_id') or item.get('file_id') if current_item_id == item_id: item_copy = item.copy() # Возвращаем копию, чтобы не изменять исходные данные # --- Преобразование типов --- decimal_fields = [] int_fields = [] try: # Обернем преобразования в try-except для большей устойчивости if item_list_name == 'materials': decimal_fields = ['quantity', 'price_per_unit'] int_fields = ['items_per_unit'] elif item_list_name == 'cutting_tasks': decimal_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost'] int_fields = ['cut_items_quantity'] elif item_list_name == 'sewing_tasks': # --- ИЗМЕНЕНО: Добавлено поле fittings_cost --- 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 # Для 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 # '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 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 = request.form.get('fabric_quantity') size_range = request.form.get('size_range', '').strip() items_quantity = request.form.get('items_quantity') prepayment = request.form.get('prepayment', '0') if not all([client_id, model_name, fabric_name, fabric_quantity, items_quantity]): 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')) # 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': fabric_quantity, 'size_range': size_range, 'items_quantity': items_quantity, 'prepayment': to_decimal(prepayment), 'status': 'pending_procurement', # Initial status 'timestamp_created': creation_time, 'timestamp': creation_time, 'is_procured': False, # Explicitly set to False for new orders 'fittings': [] } # Add fittings if any fitting_names = request.form.getlist('fitting_names[]') fitting_quantities = request.form.getlist('fitting_quantities[]') for i in range(len(fitting_names)): name = fitting_names[i].strip() qty = fitting_quantities[i].strip() if name and qty: # Only add if both name and quantity are provided new_order['fittings'].append({ 'fitting_name': name, 'quantity': qty }) # Initialize orders list if not exists if 'orders' not in data: data['orders'] = [] # Add new order and save 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')) if request.method == 'POST': try: client_id = request.form.get('client_id') model_name = request.form.get('model_name', '').strip() fabric_id = request.form.get('fabric_id') fabric_quantity = request.form.get('fabric_quantity') size_range = request.form.get('size_range', '').strip() items_quantity = request.form.get('items_quantity') # Получаем фурнитуру из формы fitting_ids = request.form.getlist('fitting_ids[]') fitting_quantities = request.form.getlist('fitting_quantities[]') # Валидация основных полей if not all([client_id, model_name, fabric_id, fabric_quantity, items_quantity]): flash("Заполните все обязательные поля заказа.", "danger") return redirect(url_for('orders')) # Находим клиента client = find_client_by_id(client_id) if not client: flash("Выбранный клиент не найден.", "danger") return redirect(url_for('orders')) # Создаем новый заказ creation_time = get_current_time().isoformat() # Create new order new_order = { 'id': uuid.uuid4().hex, 'client_id': client_id, 'client_name': client.get('name', 'N/A'), 'model_name': model_name, 'fabric_name': request.form.get('fabric_name'), # Store fabric name 'fabric_quantity': fabric_quantity, 'size_range': size_range, 'items_quantity': items_quantity, 'prepayment': to_decimal(request.form.get('prepayment', '0')), 'status': 'pending', 'timestamp_created': creation_time, 'timestamp': creation_time, 'fittings': [] } # Add fittings fitting_names = request.form.getlist('fitting_names[]') fitting_quantities = request.form.getlist('fitting_quantities[]') for i in range(len(fitting_names)): name = fitting_names[i].strip() qty = fitting_quantities[i].strip() if name and qty: new_order['fittings'].append({ 'fitting_name': name, 'quantity': qty }) # Initialize orders list if not exists if 'orders' not in data: data['orders'] = [] # Add new order and save 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 = data.get('orders', []) for order in orders_list: if isinstance(order, dict): # Добавляем информацию о материалах fabric = find_item_by_id(order.get('fabric_id'), 'materials') if fabric: order['fabric_name'] = fabric.get('name', 'N/A') order['fabric_unit'] = fabric.get('unit', 'м') # Добавляем информацию о фурнитуре for f in order.get('fittings', []): fitting = find_item_by_id(f.get('fitting_id'), 'materials') if fitting: f['fitting_name'] = fitting.get('name', 'N/A') # Убедимся что все заказы имеют 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=fabrics, fittings=fittings, orders=orders_list) # Маршрут редактирования заказа @app.route('/orders/edit/', methods=['POST']) def edit_order(): data = load_data() order_id = request.form.get('order_id') # Находим заказ для редактирования orders = data.get('orders', []) order_index = None for i, order in enumerate(orders): if isinstance(order, dict) and order.get('id') == order_id: order_index = i break if order_index is None: flash("Заказ не найден.", "danger") return redirect(url_for('orders')) try: # Обновляем данные заказа orders[order_index].update({ 'model_name': request.form.get('model_name', '').strip(), 'fabric_id': request.form.get('fabric_id'), 'fabric_quantity': request.form.get('fabric_quantity'), 'size_range': request.form.get('size_range', '').strip(), 'items_quantity': request.form.get('items_quantity'), }) # Обновляем фурнитуру fitting_ids = request.form.getlist('fitting_ids[]') fitting_quantities = request.form.getlist('fitting_quantities[]') orders[order_index]['fittings'] = [] for i in range(len(fitting_ids)): if fitting_ids[i] and fitting_quantities[i]: orders[order_index]['fittings'].append({ 'fitting_id': fitting_ids[i], 'quantity': fitting_quantities[i] }) save_data(data) flash("Заказ успешно обновлен.", "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('orders')) # Маршрут удаления заказа @app.route('/orders/delete/', methods=['POST']) def delete_order(order_id): data = load_data() orders = data.get('orders', []) # Находим и удаляем заказ for i, order in enumerate(orders): if isinstance(order, dict) and order.get('id') == order_id: if order.get('status') != 'completed': del orders[i] data['orders'] = orders save_data(data) flash("Заказ успешно удален.", "success") upload_db_to_hf(DATA_FILE) else: flash("Нельзя удалить выполненный заказ.", "danger") break else: flash("Заказ не найден.", "warning") return redirect(url_for('orders')) # 1. Маршрут "Закуп" @app.route('/procurement', methods=['GET', 'POST']) def procurement(): data = load_data() categories = data.get('categories', []) # Get pending procurement orders (not procured) pending_orders = [ order for order in data.get('orders', []) if isinstance(order, dict) and order.get('status') == 'pending_procurement' and not order.get('is_procured', False) ] if request.method == 'POST': order_id = request.form.get('order_id') if order_id: # Find the order for order in data.get('orders', []): if isinstance(order, dict) and order.get('id') == order_id: order['is_procured'] = True order['status'] = 'pending' # Change status to pending (for cutting) order['procurement_timestamp'] = get_current_time().isoformat() save_data(data) flash(f"Заказ '{order.get('model_name')}' отмечен как закупленный и готов к раскрою.", "success") upload_db_to_hf(DATA_FILE) break return redirect(url_for('procurement')) # Format orders for display procurement_orders = [] for order in pending_orders: if 'material_requirements' in order: proc_order = { 'id': order.get('id'), 'model_name': order.get('model_name'), 'client_name': order.get('client_name'), 'requirements': order['material_requirements'] } procurement_orders.append(proc_order) if request.method == 'POST': try: materials_to_add = [] valid_items_processed = 0 # Счетчик успешно обработанных строк item_names = request.form.getlist('item_name[]') # Проверка, что хотя бы одна строка была отправлена и не пуста if not item_names or all(not name.strip() for name in item_names): flash("Не добавлено ни одного товара. Заполните хотя бы одну строку.", "warning") return redirect(url_for('procurement')) item_quantities = request.form.getlist('item_quantity[]') item_units = request.form.getlist('item_unit[]') item_prices = request.form.getlist('item_price_per_unit[]') item_per_unit = request.form.getlist('item_per_unit[]') # Количество изделий на единицу (для фурнитуры) item_types = request.form.getlist('item_type[]') item_categories = request.form.getlist('item_category[]') item_new_categories = request.form.getlist('item_new_category[]') procurement_time = get_current_time().isoformat() current_materials = data.get('materials', []) # Получаем текущий список материалов for i in range(len(item_names)): name = item_names[i].strip() quantity_str = item_quantities[i] unit = item_units[i] price_str = item_prices[i] items_per_unit_str = item_per_unit[i] item_type = item_types[i] category = item_categories[i] new_category = item_new_categories[i].strip() # Пропускаем пустые строки, которые могли быть добавлены динамически if not name and not quantity_str and not price_str and not category and not new_category: continue # Валидация обязательных полей для НЕпустой строки if not name or not quantity_str or not unit or not price_str or not item_type: flash(f"Ошибка в строке {i+1}: Необходимо заполнить название, количество, единицу измерения, цену за единицу и тип.", "danger") continue # Пропускаем эту строку, но продолжаем обработку остальных # Преобразование и валидация чисел try: quantity = to_decimal(quantity_str) price = to_decimal(price_str) except InvalidOperation: flash(f"Ошибка в строке {i+1}: Некорректный формат числа для количества или цены.", "danger") continue if quantity <= 0: flash(f"Ошибка в строке {i+1}: Количество должно быть больше нуля.", "danger") continue if price < 0: flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger") continue # Обработка "На ед." (items_per_unit) items_per_unit = 0 if items_per_unit_str: try: items_per_unit = int(to_decimal(items_per_unit_str).to_integral_value()) except (InvalidOperation, ValueError): flash(f"Предупреждение в строке {i+1}: Некорректное значение 'На ед.', установлено 0.", "warning"); items_per_unit = 0 if items_per_unit < 0: items_per_unit = 0 # Определение категории final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории") # Добавляем новую категорию в общий список, если её там нет # Убедимся, что работаем со списком строк current_valid_categories = [c for c in categories if isinstance(c, str)] if new_category and final_category not in current_valid_categories: current_valid_categories.append(final_category) categories = current_valid_categories # Обновляем основной список # Поиск существующего материала (по названию, типу и категории) existing_material_index = -1 for idx, mat in enumerate(current_materials): # Сравниваем lowercase для имени и учитываем тип и категорию if isinstance(mat, dict) and \ mat.get('name','').lower() == name.lower() and \ mat.get('type') == item_type and \ mat.get('category', 'Без категории') == final_category: existing_material_index = idx break if existing_material_index != -1: # --- Обновляем существующий материал --- existing_material = current_materials[existing_material_index] # Обновляем цену (берем последнюю закупочную) existing_material['price_per_unit'] = str(price) # Добавляем количество current_quantity = to_decimal(existing_material.get('quantity', '0')) new_quantity = current_quantity + quantity existing_material['quantity'] = str(new_quantity) # Обновляем единицу измерения (на всякий случай) existing_material['unit'] = unit # Обновляем items_per_unit existing_material['items_per_unit'] = items_per_unit # Обновляем время последнего обновления existing_material['timestamp_last_updated'] = procurement_time logging.info(f"Материал '{name}' обновлен. Новое количество: {new_quantity}, Цена: {price}, Категория: {final_category}") valid_items_processed += 1 else: # --- Добавляем новый материал --- new_material = { 'id': uuid.uuid4().hex, 'name': name, 'quantity': str(quantity), 'unit': unit, 'price_per_unit': str(price), 'material_per_unit': items_per_unit, # Переименовано: теперь хранит расход материала на единицу продукции 'type': item_type, 'category': final_category, 'timestamp_added': procurement_time, 'timestamp_last_updated': procurement_time } materials_to_add.append(new_material) logging.info(f"Новый материал '{name}' добавлен. Количество: {quantity}, Цена: {price}, Категория: {final_category}") valid_items_processed += 1 # Сохраняем данные, если хотя бы одна позиция была успешно обработана if valid_items_processed > 0 : if materials_to_add: data['materials'].extend(materials_to_add) # Добавляем новые # data['materials'] уже содержит обновленные элементы, если были только обновления # Обновляем и сортируем список категорий (только строки) data['categories'] = sorted(list(set(c for c in categories if isinstance(c, str))), key=str.lower) save_data(data) flash(f"Закуп успешно зарегистрирован! Обработано {valid_items_processed} позиций.", "success") upload_db_to_hf(DATA_FILE) # Запускаем бэкап else: # Если были ошибки во всех строках или все строки были пустые/невалидные flash("Не было добавлено или обновлено ни одной валидной позиции.", "warning") return redirect(url_for('procurement')) except Exception as e: logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True) flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger") return redirect(url_for('procurement')) # GET запрос: отображаем страницу # Добавляем форматированные строки для отображения в шаблоне materials_display = [] for m in data.get('materials', []): if isinstance(m, dict) and 'id' in m: # Доп. проверка m_data = find_item_by_id(m['id'], 'materials') if m_data: # Форматирование уже есть в find_item_by_id, но оставим для ясности if m_data.get('type') == 'fabric': m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) else: m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) m_data['price_str'] = format_currency_py(m_data.get('price_per_unit', '0.00')) materials_display.append(m_data) # Фильтруем категории, оставляем только строки valid_categories = [c for c in categories if isinstance(c, str)] # Get pending orders for procurement pending_orders = [ order for order in data.get('orders', []) if isinstance(order, dict) and order.get('status') == 'pending_procurement' and not order.get('is_procured', False) ] html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) return render_template_string(html, categories=valid_categories, materials_display=materials_display, orders=pending_orders) # 2. Маршрут "Раскрой" @app.route('/cutting', methods=['GET', 'POST']) def cutting(): data = load_data() # Get pending procurement orders (not procured) pending_orders = [] for order in data.get('orders', []): if isinstance(order, dict) and order.get('status') == 'pending_procurement' and not order.get('is_procured', False): pending_orders.append(order) # Фильтруем материалы, оставляем только ткани с положительным количеством fabrics = [] for m in data.get('materials', []): if isinstance(m, dict) and m.get('type') == 'fabric': # Используем to_decimal для проверки количества if to_decimal(m.get('quantity', '0')) > 0: fabrics.append(m) config = data.get('config', {}) if request.method == 'POST': try: fabric_id = request.form.get('fabric_id') cut_items_quantity_str = request.form.get('cut_items_quantity') fabric_used_str = request.form.get('fabric_used') # Валидация входных данных if not fabric_id or not cut_items_quantity_str or not fabric_used_str: flash("Необходимо выбрать ткань и заполнить все поля.", "danger") return redirect(url_for('cutting')) # Ищем выбранную ткань в данных fabric_material = find_item_by_id(fabric_id, 'materials') if not fabric_material: # find_item_by_id вернет None, если не найдено или ошибка flash("Выбранная ткань не найдена в базе данных.", "danger") return redirect(url_for('cutting')) # Преобразование и валидация чисел try: cut_items_quantity = int(to_decimal(cut_items_quantity_str).to_integral_value()) if cut_items_quantity <= 0: raise ValueError("Количество должно быть > 0") except (InvalidOperation, ValueError): flash("Некорректное количество раскроенных изделий. Введите целое положительное число.", "danger") return redirect(url_for('cutting')) try: fabric_used = to_decimal(fabric_used_str) if fabric_used <= 0: raise ValueError("Расход ткани должен быть > 0") except (InvalidOperation, ValueError): flash("Некорректное значение использованной ткани. Введите положительное число.", "danger") return redirect(url_for('cutting')) # Проверка наличия достаточного количества ткани # find_item_by_id уже вернул quantity как Decimal available_quantity = fabric_material.get('quantity', Decimal('0.00')) if fabric_used > available_quantity: flash(f"Недостаточно ткани '{fabric_material['name']}'. " f"В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, " f"требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger") return redirect(url_for('cutting')) # Расчет стоимостей (на основе текущих данных) # find_item_by_id уже вернул price_per_unit как Decimal price_per_unit = fabric_material.get('price_per_unit', Decimal('0.00')) material_cost = fabric_used * price_per_unit salary_cutter_per_unit = to_decimal(config.get('salary_cutter_per_unit', '0.00')) cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter_per_unit # Создание записи о задании на раскрой creation_time = get_current_time().isoformat() cutting_task = { 'id': uuid.uuid4().hex, 'fabric_id': fabric_id, 'fabric_name': fabric_material['name'], # Сохраняем имя для удобства 'fabric_unit': fabric_material['unit'], # Сохраняем единицу измерения 'cut_items_quantity': cut_items_quantity, # int 'fabric_used': str(fabric_used), # Сохраняем как строку 'status': 'pending', # Начальный статус - ожидает пошива 'timestamp_created': creation_time, 'timestamp_completed': None, # Время завершения (когда начнется пошив) 'material_cost': str(material_cost), # Стоимость израсходованной ткани 'cutting_salary_cost': str(cutting_salary_cost) # Стоимость работы раскройщика } # Обновление остатка ткани new_available_quantity = available_quantity - fabric_used material_updated = False current_materials = data.get('materials', []) for i, mat in enumerate(current_materials): if isinstance(mat, dict) and mat.get('id') == fabric_id: # Обновляем количество и время последнего изменения current_materials[i]['quantity'] = str(new_available_quantity.quantize(Decimal('0.01'))) # Округляем до 2 знаков current_materials[i]['timestamp_last_updated'] = creation_time material_updated = True break if not material_updated: # Это не должно произойти, если find_item_by_id сработал, но на всякий случай flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger") return redirect(url_for('cutting')) # Добавление задания в список и сохранение данных if 'cutting_tasks' not in data: data['cutting_tasks'] = [] data['cutting_tasks'].append(cutting_task) # data['materials'] уже содержит обновленный список материалов save_data(data) flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success") upload_db_to_hf(DATA_FILE) # Бэкап return redirect(url_for('cutting')) except Exception as e: logging.error(f"Ошибка при регистрации раскроя: {e}", exc_info=True) flash(f"Произошла внутренняя ошибка при регистрации раскроя: {e}", "danger") return redirect(url_for('cutting')) # GET запрос: отображаем страницу # Преобразуем данные о ткани для отображения fabrics_display = [] for f in fabrics: if isinstance(f, dict) and 'id' in f: # Доп. проверка f_copy = find_item_by_id(f['id'], 'materials') # Получаем данные с преобразованными типами if f_copy: f_copy['quantity_str'] = format_currency_py(f_copy.get('quantity', '0.00')) # Форматируем для отображения fabrics_display.append(f_copy) html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Раскрой ткани").replace('__CONTENT__', CUTTING_CONTENT).replace('__SCRIPTS__', CUTTING_SCRIPTS) return render_template_string(html, fabrics=fabrics_display) # 3. Маршрут "Пошив" @app.route('/sewing', methods=['GET', 'POST']) def sewing(): data = load_data() # Находим задания раскроя, ожидающие пошива pending_cutting_tasks = [] for t in data.get('cutting_tasks', []): if isinstance(t, dict) and t.get('status') == 'pending': pending_cutting_tasks.append(t) # Находим доступную фурнитуру available_fittings = [] for m in data.get('materials', []): if isinstance(m, dict) and m.get('type') == 'fittings': # Преобразуем в int для фурнитуры перед сравнением if int(to_decimal(m.get('quantity', '0'))) > 0: available_fittings.append(m) # Все материалы (для выбора брака) all_materials = [m for m in data.get('materials', []) if isinstance(m, dict)] config = data.get('config', {}) if request.method == 'POST': try: cutting_task_id = request.form.get('cutting_task_id') sewn_product_name = request.form.get('sewn_product_name', '').strip() sewn_quantity_str = request.form.get('sewn_quantity') # Фурнитура fitting_ids = request.form.getlist('fitting_ids[]') fitting_quantities = request.form.getlist('fitting_quantities[]') # Брак defect_material_ids = request.form.getlist('defect_material_id[]') defect_quantities = request.form.getlist('defect_quantity[]') # Валидация основных полей if not cutting_task_id or not sewn_product_name or not sewn_quantity_str: flash("Необходимо выбрать задание на раскрой, указать название изделия и количество сшитых.", "danger") return redirect(url_for('sewing')) # Находим задание на раскрой cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') if not cutting_task or cutting_task.get('status') != 'pending': flash("Выбранное задание на раскрой не найдено или уже не находится в статусе 'Ожидает пошива'.", "danger") return redirect(url_for('sewing')) # Валидация количества сшитых try: sewn_quantity = int(to_decimal(sewn_quantity_str).to_integral_value()) # find_item_by_id уже вернул cut_items_quantity как int cut_quantity = cutting_task.get('cut_items_quantity', 0) if sewn_quantity <= 0: raise ValueError("Кол-во > 0") if sewn_quantity > cut_quantity: flash(f"Количество сшитых ({sewn_quantity}) не может превышать количество раскроенных ({cut_quantity}).", "danger") return redirect(url_for('sewing')) except (InvalidOperation, ValueError): flash("Некорректное количество сшитых изделий. Введите целое положительное число.", "danger") return redirect(url_for('sewing')) # --- Обработка фурнитуры --- fittings_consumed = [] fittings_total_cost = Decimal('0') materials_to_update = {} # Словарь {material_id: quantity_to_deduct} для атомарного списания sewing_time = get_current_time().isoformat() is_valid = True if fitting_ids and fitting_quantities and len(fitting_ids) == len(fitting_quantities): for i in range(len(fitting_ids)): fitting_id = fitting_ids[i] quantity_str = fitting_quantities[i] if not fitting_id or not quantity_str: continue # Пропускаем пустые строки фурнитуры fitting_material = find_item_by_id(fitting_id, 'materials') if not fitting_material or fitting_material.get('type') != 'fittings': flash(f"Ошибка: Фурнитура с ID {fitting_id} не найдена или не является фурнитурой.", "danger") is_valid = False; break try: # Количество фурнитуры вводится как целое число quantity_used = int(to_decimal(quantity_str).to_integral_value()) if quantity_used <= 0: raise ValueError("Кол-во > 0") except (InvalidOperation, ValueError): flash(f"Некорректное количество для фурнитуры '{fitting_material['name']}'. Введите целое положительное число.", "danger") is_valid = False; break # Проверка доступности с учетом уже запланированного списания # find_item_by_id вернул quantity как Decimal, преобразуем в int для фурнитуры available_qty_int = int(fitting_material.get('quantity', Decimal('0'))) # materials_to_update хранит Decimal, преобразуем в int для сравнения 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']}'. " f"В наличии: {format_integer_py(available_qty_int)}, " f"уже запланировано списать: {format_integer_py(planned_deduction_int)}, " f"требуется еще: {format_integer_py(quantity_used)}.", "danger") is_valid = False; break # Добавляем в план списания (используем Decimal для единообразия в словаре) materials_to_update[fitting_id] = materials_to_update.get(fitting_id, Decimal('0')) + Decimal(quantity_used) # Считаем стоимость фурнитуры price = fitting_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal cost = price * Decimal(quantity_used) fittings_total_cost += cost # Добавляем в список использованной фурнитуры для задачи пошива fittings_consumed.append({ 'fitting_id': fitting_id, 'fitting_name': fitting_material['name'], 'quantity_used': quantity_used, # Сохраняем int 'cost': str(cost) # Сохраняем строку }) if not is_valid: return redirect(url_for('sewing')) # --- Обработка брака --- defects_reported = [] if defect_material_ids and defect_quantities and len(defect_material_ids) == len(defect_material_ids): for i in range(len(defect_material_ids)): material_id = defect_material_ids[i] quantity_str = defect_quantities[i] if not material_id or not quantity_str: continue # Пропускаем пустые строки брака defect_material = find_item_by_id(material_id, 'materials') if not defect_material: flash(f"Предупреждение: Материал для брака с ID {material_id} не найден.", "warning") continue # Пропускаем, но не останавливаем процесс # Валидация количества брака в зависимости от типа material_type = defect_material.get('type') quantity_deduct = Decimal('0') quantity_log_value = 0 # Значение для записи в лог (int или Decimal) is_fabric = material_type == 'fabric' try: if is_fabric: quantity_deduct = to_decimal(quantity_str) if quantity_deduct <= 0: raise ValueError("Кол-во ткани > 0") quantity_log_value = quantity_deduct # Decimal для лога else: # fittings quantity_int = int(to_decimal(quantity_str).to_integral_value()) if quantity_int <= 0: raise ValueError("Кол-во фурн. > 0") quantity_deduct = Decimal(quantity_int) quantity_log_value = quantity_int # int для лога except (InvalidOperation, ValueError): flash(f"Некорректное количество брака для '{defect_material['name']}'.", "warning") continue # Пропускаем эту запись брака # Проверка доступности с учетом уже запланированного available_qty = defect_material.get('quantity', Decimal('0')) # Уже Decimal planned_deduction = materials_to_update.get(material_id, Decimal('0')) effective_available = available_qty - planned_deduction if effective_available < quantity_deduct: available_str = format_currency_py(effective_available) if is_fabric else format_integer_py(effective_available) deduct_str = format_currency_py(quantity_deduct) if is_fabric else format_integer_py(quantity_deduct) flash(f"Недостаточно '{defect_material['name']}' для списания в брак ({deduct_str} {defect_material['unit']}). " f"Доступно с учетом других списаний: {available_str} {defect_material['unit']}.", "danger") is_valid = False; break # Добавляем в план списания materials_to_update[material_id] = planned_deduction + quantity_deduct # Считаем стоимость брака price = defect_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal defect_cost = price * quantity_deduct # Готовим запись для лога брака defect_entry = { 'log_id': uuid.uuid4().hex, 'material_id': material_id, 'material_name': defect_material['name'], 'quantity': str(quantity_log_value) if is_fabric else int(quantity_log_value), # Строка Decimal или int 'unit': defect_material['unit'], 'type': material_type, 'stage': 'sewing', # Этап, на котором обнаружен брак 'reason': 'Брак при пошиве', # Можно сделать поле в форме 'cost': str(defect_cost), # Сохраняем строку 'sewing_task_id': None, # Будет установлен после создания задачи пошива 'timestamp': sewing_time } defects_reported.append(defect_entry) if not is_valid: return redirect(url_for('sewing')) # --- Списание материалов (фурнитура + брак) --- current_materials = data.get('materials', []) for material_id, quantity_to_deduct in materials_to_update.items(): material_updated = False for i, mat in enumerate(current_materials): if isinstance(mat, dict) and mat.get('id') == material_id: current_qty = to_decimal(mat.get('quantity', '0')) new_qty = current_qty - quantity_to_deduct # Округление в зависимости от типа if mat.get('type') == 'fabric': new_qty = new_qty.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) else: # fittings new_qty = new_qty.quantize(Decimal('0'), rounding=ROUND_HALF_UP) # Округляем до целого (0 знаков) # Убедимся, что не ушли в минус if new_qty < 0: new_qty = Decimal('0') current_materials[i]['quantity'] = str(new_qty) current_materials[i]['timestamp_last_updated'] = sewing_time material_updated = True 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") return redirect(url_for('sewing')) # --- Расчет зарплаты швеи --- salary_sewer_per_unit = to_decimal(config.get('salary_sewer_per_unit', '0.00')) sewing_salary_cost = Decimal(sewn_quantity) * salary_sewer_per_unit # --- Создание задачи пошива --- sewing_task = { 'id': uuid.uuid4().hex, 'cutting_task_id': cutting_task_id, 'product_name': sewn_product_name, 'sewn_quantity': sewn_quantity, # int 'fabric_id': cutting_task['fabric_id'], # Для справки 'fabric_name': cutting_task['fabric_name'], # Для справки 'fittings_consumed': fittings_consumed, # Список использованной фурнитуры 'defects_reported': [], # Сюда добавим записи из defects_reported 'status': 'pending_qc', # Отправляем на ОТК 'timestamp_created': sewing_time, 'timestamp_completed': None, # Время завершения (после ОТК/упаковки) 'qc_packed_quantity': 0, # Количество, прошедшее ОТК 'qc_defective_quantity': 0, # Количество брака на этапе ОТК 'fittings_cost': str(fittings_total_cost), # Общая стоимость фурнитуры 'sewing_salary_cost': str(sewing_salary_cost), # ЗП швеи # Переносим стоимость ЗП раскройщика из задачи раскроя (уже строка Decimal) 'cutting_salary_cost': cutting_task.get('cutting_salary_cost', '0.00') } # Привязываем ID задачи пошива к записям о браке и добавляем их в задачу for defect in defects_reported: defect['sewing_task_id'] = sewing_task['id'] sewing_task['defects_reported'].append(defect) # --- Обновление статуса задачи раскроя --- current_cutting_tasks = data.get('cutting_tasks', []) cutting_task_updated = False for i, task in enumerate(current_cutting_tasks): if isinstance(task, dict) and task.get('id') == cutting_task_id and task.get('status') == 'pending': current_cutting_tasks[i]['status'] = 'completed' current_cutting_tasks[i]['timestamp_completed'] = sewing_time cutting_task_updated = True logging.info(f"Статус задания на раскрой {cutting_task_id} изменен на 'completed'.") break elif isinstance(task, dict) and task.get('id') == cutting_task_id: # Задача найдена, но статус уже не pending (маловероятно из-за проверки выше) cutting_task_updated = True # Считаем, что обработали, раз нашли logging.warning(f"Попытка обновить статус для уже обработанного задания раскроя {cutting_task_id}.") break if not cutting_task_updated: # Этого тоже не должно произойти logging.error(f"Критическая ошибка: Не удалось найти и обновить статус задания на раскрой {cutting_task_id}.") flash(f"Критическая ошибка при обновлении статуса задания раскроя {cutting_task_id}.", "danger") # Решаем, откатывать ли транзакцию или продолжить с предупреждением # Пока продолжим, но залогировали ошибку. # --- Сохранение всех изменений --- if 'sewing_tasks' not in data: data['sewing_tasks'] = [] data['sewing_tasks'].append(sewing_task) # Добавляем брак в общий лог брака, если он был if defects_reported: if 'defect_log' not in data: data['defect_log'] = [] data['defect_log'].extend(sewing_task['defects_reported']) # Добавляем те же объекты # data['materials'] и data['cutting_tasks'] уже обновлены save_data(data) flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' успешно зарегистрирован. Статус: Ожидает ОТК.", "success") if 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 запрос: отображаем страницу # Готовим данные для шаблона tasks_for_template = [] for task in pending_cutting_tasks: if isinstance(task, dict) and 'id' in task: task_data = find_item_by_id(task['id'], 'cutting_tasks') if task_data: task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00')) tasks_for_template.append(task_data) fittings_for_template = [] for f in available_fittings: if isinstance(f, dict) and 'id' in f: f_data = find_item_by_id(f['id'], 'materials') if f_data: # Фурнитура имеет целое количество f_data['quantity_str'] = format_integer_py(f_data.get('quantity', '0')) fittings_for_template.append(f_data) all_materials_for_template = [] for m in all_materials: if isinstance(m, dict) and 'id' in m: m_data = find_item_by_id(m['id'], 'materials') if m_data: if m_data.get('type') == 'fabric': m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) else: m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) all_materials_for_template.append(m_data) html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Пошив изделий").replace('__CONTENT__', SEWING_CONTENT).replace('__SCRIPTS__', SEWING_SCRIPTS) return render_template_string(html, cutting_tasks=tasks_for_template, fittings=fittings_for_template, all_materials=all_materials_for_template) # 4. Маршрут "ОТК и Упаковка" @app.route('/qc_packing', methods=['GET', 'POST']) def qc_packing(): data = load_data() # Находим задания пошива, ожидающие ОТК pending_qc_tasks = [] for t in data.get('sewing_tasks', []): if isinstance(t, dict) and t.get('status') == 'pending_qc': pending_qc_tasks.append(t) config = data.get('config', {}) if request.method == 'POST': try: sewing_task_id = request.form.get('sewing_task_id') quantity_packed_str = request.form.get('quantity_packed') quantity_defective_str = request.form.get('quantity_defective', '0') # По умолчанию 0 defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() # Валидация выбора задания if not sewing_task_id: flash("Необходимо выбрать задание на пошив.", "danger") return redirect(url_for('qc_packing')) # Находим задание пошива sewing_task = find_item_by_id(sewing_task_id, 'sewing_tasks') if not sewing_task or sewing_task.get('status') != 'pending_qc': flash("Выбранное задание на пошив не найдено или уже не ожидает ОТК.", "danger") return redirect(url_for('qc_packing')) # Валидация количества try: quantity_packed = int(to_decimal(quantity_packed_str).to_integral_value()) if quantity_packed_str else 0 quantity_defective = int(to_decimal(quantity_defective_str).to_integral_value()) if quantity_defective_str else 0 if quantity_packed < 0 or quantity_defective < 0: raise ValueError("Количество не может быть отрицательным") total_processed_now = quantity_packed + quantity_defective if total_processed_now <= 0: flash("Необходимо указать количество упакованных или бракованных изделий (сумма должна быть > 0).", "warning") return redirect(url_for('qc_packing')) # Рассчитываем, сколько осталось обработать по этому заданию # find_item_by_id уже вернул int для этих полей total_sewn = sewing_task.get('sewn_quantity', 0) already_packed = sewing_task.get('qc_packed_quantity', 0) already_defective = sewing_task.get('qc_defective_quantity', 0) remaining_to_process = total_sewn - (already_packed + already_defective) if total_processed_now > remaining_to_process: flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed_now}, " f"что превышает остаток изделий для обработки ({remaining_to_process}).", "danger") return redirect(url_for('qc_packing')) except (InvalidOperation, ValueError) as e: flash(f"Некорректное количество упакованных или бракованных изделий: {e}", "danger") return redirect(url_for('qc_packing')) qc_time = get_current_time().isoformat() new_packed_item_entry = None # Запись для qc_packing_items new_defect_log_entry = None # Запись для defect_log # --- Обработка упакованных (прошедших ОТК) --- if quantity_packed > 0: # Получаем связанные данные для расчета себестоимости cutting_task_id = sewing_task.get('cutting_task_id') # Используем find_item_by_id, который вернет данные с Decimal/int cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') # Если задание на раскрой не найдено, используем нули if not cutting_task: logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости для пошива {sewing_task_id}.") cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} # Получаем стоимости из задач (уже в Decimal) fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) # --- ИЗМЕНЕНО: получаем стоимость фурнитуры из задачи пошива --- fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) # Получаем количество из задач для расчета на единицу (уже int) cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 # Избегаем деления на ноль sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 # Избегаем деления на ноль # Расчет себестоимости на 1 изделие fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) # --- ИЗМЕНЕНО: добавляем расчет стоимости фурнитуры на 1 изделие --- fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item # Общая стоимость материалов cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) packing_salary_per_item = to_decimal(config.get('salary_packer_per_unit', '0.00')) salary_cost_per_item = cutting_salary_per_item + sewing_salary_per_item + packing_salary_per_item # Общая стоимость ЗП total_cost_per_item = material_cost_per_item + salary_cost_per_item # Полная себестоимость 1 ед. # Расчет цены продажи на 1 изделие margin_per_item = to_decimal(config.get('margin_per_item', '0.00')) final_price_per_item = total_cost_per_item + margin_per_item # Расчет общих сумм для текущей пачки упакованных packed_batch_material_cost = material_cost_per_item * Decimal(quantity_packed) packed_batch_salary_cost = salary_cost_per_item * Decimal(quantity_packed) packed_batch_total_cost = total_cost_per_item * Decimal(quantity_packed) packed_batch_margin = margin_per_item * Decimal(quantity_packed) packed_batch_final_price = final_price_per_item * Decimal(quantity_packed) # Создаем запись об упакованном товаре new_packed_item_entry = { 'id': uuid.uuid4().hex, 'sewing_task_id': sewing_task_id, 'product_name': sewing_task['product_name'], 'quantity': quantity_packed, # int - количество в ЭТОЙ партии 'timestamp_packed': qc_time, 'packed_material_cost': str(packed_batch_material_cost), 'packed_salary_cost': str(packed_batch_salary_cost), 'packed_total_cost': str(packed_batch_total_cost), # Себестоимость этой партии 'packed_margin': str(packed_batch_margin), # Маржа этой партии 'packed_final_price': str(packed_batch_final_price), # Цена продажи этой партии 'status': 'packed_ready_to_ship', # Готово к отправке 'shipment_details': None # Детали отправки будут добавлены позже } if 'qc_packing_items' not in data: data['qc_packing_items'] = [] data['qc_packing_items'].append(new_packed_item_entry) # --- Обработка брака на этапе ОТК --- if quantity_defective > 0: # Рассчитываем себестоимость бракованного изделия (без ЗП упаковщика и без маржи) cutting_task_id = sewing_task.get('cutting_task_id') cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') if not cutting_task: logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости брака ОТК для {sewing_task_id}.") cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} # Стоимости и кол-во уже в нужных типах из find_item_by_id fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) # --- ИЗМЕНЕНО: получаем стоимость фурнитуры из задачи пошива --- fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) # --- ИЗМЕНЕНО: добавляем стоимость фурнитуры --- fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) salary_cost_per_defective_item = cutting_salary_per_item + sewing_salary_per_item # ЗП упаковщика не учитываем # Себестоимость 1 бракованного изделия cost_per_defective_item = material_cost_per_item + salary_cost_per_defective_item # Общая стоимость списания брака total_defect_cost = cost_per_defective_item * Decimal(quantity_defective) # Создаем запись для лога брака new_defect_log_entry = { 'log_id': uuid.uuid4().hex, 'material_id': None, # Брак не конкретного материала, а готового изделия 'material_name': f"{sewing_task['product_name']} (готовое изделие)", 'quantity': quantity_defective, # int 'unit': 'шт', 'type': 'finished_product', # Тип брака - готовое изделие 'stage': 'qc_packing', # Этап обнаружения 'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке', 'cost': str(total_defect_cost), # Общая стоимость списания 'sewing_task_id': sewing_task_id, # Ссылка на задачу пошива 'timestamp': qc_time } if 'defect_log' not in data: data['defect_log'] = [] data['defect_log'].append(new_defect_log_entry) logging.info(f"Зарегистрирован брак ОТК: {quantity_defective} ед. '{sewing_task['product_name']}' (Общая стоимость: {format_currency_py(total_defect_cost)})") # --- Обновление задачи пошива --- sewing_task_updated = False current_sewing_tasks = data.get('sewing_tasks', []) for i, task in enumerate(current_sewing_tasks): if isinstance(task, dict) and task.get('id') == sewing_task_id: # Увеличиваем счетчики обработанных current_sewing_tasks[i]['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) + quantity_packed current_sewing_tasks[i]['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) + quantity_defective # Проверяем, завершена ли обработка всего задания total_processed_for_task = current_sewing_tasks[i]['qc_packed_quantity'] + current_sewing_tasks[i]['qc_defective_quantity'] if total_processed_for_task >= int(task.get('sewn_quantity', 0)): # Если все обработано, меняем статус и ставим время завершения current_sewing_tasks[i]['status'] = 'completed' current_sewing_tasks[i]['timestamp_completed'] = qc_time logging.info(f"Задание на пошив {sewing_task_id} полностью обработано и завершено.") else: # Если обработано частично, статус остается pending_qc current_sewing_tasks[i]['status'] = 'pending_qc' # Явно оставляем статус logging.info(f"Задание на пошив {sewing_task_id} обработано частично. Осталось: {remaining_to_process - total_processed_now}") sewing_task_updated = True break if not sewing_task_updated: logging.error(f"Критическая ошибка: Не удалось найти и обновить задание на пошив {sewing_task_id}.") # Возможно, стоит откатить изменения или выдать более серьезное предупреждение flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger") # --- Сохранение данных --- # data['sewing_tasks'], data['qc_packing_items'], data['defect_log'] уже обновлены save_data(data) flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. " if new_packed_item_entry: flash_message += f"Статус упакованных: Готово к отправке." elif new_defect_log_entry: flash_message += f"Брак зарегистрирован." flash(flash_message, "success") upload_db_to_hf(DATA_FILE) # Бэкап return redirect(url_for('qc_packing')) except Exception as e: logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True) flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger") return redirect(url_for('qc_packing')) # GET запрос: отображаем страницу # Готовим список задач для шаблона, рассчитывая остаток tasks_for_template = [] for task in pending_qc_tasks: if isinstance(task, dict) and 'id' in task: task_data = find_item_by_id(task['id'], 'sewing_tasks') if task_data: total_sewn = task_data.get('sewn_quantity', 0) already_processed = task_data.get('qc_packed_quantity', 0) + task_data.get('qc_defective_quantity', 0) remaining = total_sewn - already_processed if remaining > 0: # Показываем только те, где еще есть что обрабатывать task_data['remaining_quantity'] = remaining tasks_for_template.append(task_data) html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS) return render_template_string(html, sewing_tasks=tasks_for_template) # 5. Маршрут "База клиентов" @app.route('/clients', methods=['GET', 'POST']) def clients_panel(): if request.method == 'POST': 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)) 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 for i, client in enumerate(clients): if client.get('id') == client_id: # Проверяем наличие истории у клиента if client.get('history', []): flash("Невозможно удалить клиента с историей отправок.", "warning") return redirect(url_for('clients_panel')) deleted_name = client.get('name', 'Неизвестный клиент') del clients[i] client_found = True break if client_found: save_client_data(clients) flash(f"Клиент '{deleted_name}' удален.", "success") upload_db_to_hf(CLIENT_DATA_FILE) else: 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)) 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()) # Сортировка по имени # Подготовка данных для шаблона: обработка истории # load_client_data УЖЕ гарантирует, что history и items являются списками for client in clients_data: # Сортировка истории (теперь безопасно) и парсинг дат if 'history' in client: # Проверка типа уже не нужна, т.к. load_client_data ее сделал # Сортируем исходный список (не создаем копию для сортировки тут) client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True) # Добавляем datetime объекты для удобного отображения в шаблоне for record in client['history']: # record - точно словарь record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) # Проверка record['items'] уже не нужна html = BASE_TEMPLATE.replace('__TITLE__', "База клиентов").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS) # Передаем обработанные данные в шаблон return render_template_string(html, clients=clients_data) except Exception as e: logging.error(f"Неожиданная ошибка в GET /clients: {e}", exc_info=True) flash("Произошла ошибка при отображении страницы клиентов.", "danger") # Можно перенаправить на главную или показать пустую страницу return redirect(url_for('admin_panel')) # 6. Маршрут "Админ-панель" @app.route('/admin') def admin_panel(): data = load_data() clients_data = load_client_data() # Загружаем проверенных клиентов config = data.get('config', {}) # Получаем актуальные данные с помощью find_item_by_id для всех списков # Фильтруем материалы с количеством > 0 прямо здесь all_materials = [m for m_id in [m.get('id') for m in data.get('materials', []) if isinstance(m, dict)] if (m := find_item_by_id(m_id, 'materials')) is not None and m.get('quantity', Decimal('0')) > 0] materials_count = len(all_materials) # Обновляем счетчик после фильтрации all_cutting_tasks = [t for t_id in [t.get('id') for t in data.get('cutting_tasks', []) if isinstance(t, dict)] if (t := find_item_by_id(t_id, 'cutting_tasks')) is not None] all_sewing_tasks = [s for s_id in [s.get('id') for s in data.get('sewing_tasks', []) if isinstance(s, dict)] if (s := find_item_by_id(s_id, 'sewing_tasks')) is not None] all_packed_items = [p for p_id in [p.get('id') for p in data.get('qc_packing_items', []) if isinstance(p, dict)] if (p := find_item_by_id(p_id, 'qc_packing_items')) is not None] all_defect_log = [d for d_id in [d.get('log_id') for d in data.get('defect_log', []) if isinstance(d, dict)] if (d := find_item_by_id(d_id, 'defect_log')) is not None] all_expenses = [e for e_id in [e.get('id') for e in data.get('expenses', []) if isinstance(e, dict)] if (e := find_item_by_id(e_id, 'expenses')) is not None] # Получаем историю отправок на Дордой dordoi_shipments = data.get('dordoi_shipments', []) for ship in dordoi_shipments: # Парсим даты ship['timestamp_dt'] = parse_iso_datetime(ship.get('timestamp')) dordoi_shipments.sort(key=lambda x: x.get('timestamp',''), reverse=True) # Сортируем categories = data.get('categories', []) # --- Расчет сводных данных --- items_ready_to_ship = [item for item in all_packed_items if item.get('status') == 'packed_ready_to_ship'] items_ready_ship_count = len(items_ready_to_ship) # ИЗМЕНЕНО: Суммируем количество только из готовых к отправке items_ready_ship_qty = sum(item.get('quantity', 0) for item in items_ready_to_ship) # Счетчик "Упаковано Всего" теперь считает только готовые к отправке (items_ready_ship_qty) 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 = [task for task in all_sewing_tasks if task.get('status') == 'pending_qc'] pending_qc_count = len(pending_qc_tasks) pending_qc_quantity = sum(task.get('sewn_quantity', 0) - (task.get('qc_packed_quantity', 0) + task.get('qc_defective_quantity', 0)) for task in pending_qc_tasks) # Считаем остаток 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 item_id or not destination_type or not quantity_to_dispatch_str: flash("Ошибка: Не указан ID товара, тип назначения или количество для отправки.", "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"Ошибка: Товар с ID {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("Количество должно быть положительным") 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}] # Предметы для записи в историю if destination_type == 'client': if not client_id: flash("Ошибка: Не выбран клиент для отправки.", "danger") return redirect(redirect_target) client_object_to_update = None client_name = "Клиент не найден" 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"Ошибка: Клиент с ID {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) destination_display_text = "на Торговую точку Дордой" main_data_changed = True # Так как dordoi_shipments в основном файле # Обновление основной записи (если полная отправка) 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 # Статус остается 'packed_ready_to_ship' # shipment_details не добавляется в основной элемент при частичной отправке main_data_changed = True logging.info(f"Частичная отправка товара {item_id} ({product_name}): {quantity_to_dispatch} шт. Осталось: {remaining_quantity} шт.") # --- Сохранение изменений --- if main_data_changed: # data['qc_packing_items'][item_index] уже обновлен (так как packed_item_to_update - ссылка) save_data(data) upload_db_to_hf(DATA_FILE) logging.info(f"Основные данные сохранены после отправки товара {item_id}.") if client_data_changed: save_client_data(clients) upload_db_to_hf(CLIENT_DATA_FILE) logging.info(f"Данные клиента {client_id} сохранены.") flash(f"{quantity_to_dispatch} шт. товара '{product_name}' успешно отправлено {destination_display_text}.", "success") return redirect(redirect_target) # --- Маршрут для работы с авансами --- @app.route('/advances/delete/', methods=['POST']) def delete_advance(advance_id): data = load_data() advances = data.get('advances', []) # Search and delete the advance for i, advance in enumerate(advances): if advance.get('id') == advance_id: del advances[i] data['advances'] = advances save_data(data) flash("Аванс успешно удален.", "success") upload_db_to_hf(DATA_FILE) break return redirect(url_for('advances')) @app.route('/admin/expense/delete/', methods=['POST']) def delete_expense(expense_id): data = load_data() expenses = data.get('expenses', []) # Search and delete the expense for i, expense in enumerate(expenses): if expense.get('id') == expense_id: del expenses[i] data['expenses'] = expenses save_data(data) flash("Расход успешно удален.", "success") upload_db_to_hf(DATA_FILE) break return redirect(url_for('admin_panel') + '#expenses-report-content') @app.route('/advances', methods=['GET', 'POST']) def advances(): data = load_data() if 'advances' not in data: data['advances'] = [] if 'monthly_salaries' not in data: data['monthly_salaries'] = {} 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') month_year = get_current_time().strftime('%Y-%m') # Initialize monthly salary tracking if not exists if month_year not in data['monthly_salaries']: data['monthly_salaries'][month_year] = {} if employee_name not in data['monthly_salaries'][month_year]: data['monthly_salaries'][month_year][employee_name] = { 'role': role, 'earned': Decimal('0'), 'advances': Decimal('0'), 'final_payout': Decimal('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("Сумма аванса должна быть положительной", "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 # Для отметки о вычете из ЗП } 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") advances = data.get('advances', []) advances.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) # --- Остальные маршруты админ-панели --- @app.route('/admin/config/update', methods=['POST']) def update_config(): data = load_data() config = data.get('config', {}) # Получаем текущую конфигурацию или пустой словарь try: # Получаем значения из формы, преобразуем в Decimal, затем в строку config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter'))) config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer'))) config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer'))) config['margin_per_item'] = str(to_decimal(request.form.get('margin'))) data['config'] = config # Обновляем конфиг в основных данных save_data(data) flash("Настройки зарплат и маржи успешно сохранены.", "success") upload_db_to_hf(DATA_FILE) # Бэкап except InvalidOperation: flash("Ошибка: Введено некорректное числовое значение.", "danger") except Exception as e: logging.error(f"Ошибка при обновлении конфигурации: {e}", exc_info=True) flash(f"Произошла ошибка при сохранении настроек: {e}", "danger") # Возвращаемся на админ-панель (вкладка настроек не указана, будет по умолчанию) return redirect(url_for('admin_panel')) @app.route('/admin/expense/add', methods=['POST']) def add_expense(): data = load_data() description = request.form.get('expense_description','').strip() amount_str = request.form.get('expense_amount') 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) if amount <= 0: raise ValueError("Сумма должна быть > 0") except (InvalidOperation, ValueError): flash("Некорректное значение суммы расхода. Введите положительное число.", "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() } 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/category/add', methods=['POST']) def add_category(): data = load_data() categories = data.get('categories', []) new_category_name = request.form.get('new_category_name','').strip() if not new_category_name: flash("Название категории не может быть пустым.", "warning") return redirect(url_for('admin_panel')) # Возврат на админку # Фильтруем существующие категории, оставляем только строки current_valid_categories = [c for c in categories if isinstance(c, str)] # Проверка на существование (без учета регистра) if new_category_name.lower() not in [c.lower() for c in current_valid_categories]: current_valid_categories.append(new_category_name) # Обновляем список категорий в данных и сортируем data['categories'] = sorted(list(set(current_valid_categories)), key=str.lower) save_data(data) flash(f"Категория '{new_category_name}' успешно добавлена.", "success") upload_db_to_hf(DATA_FILE) # Бэкап else: flash(f"Категория '{new_category_name}' уже существует.", "warning") return redirect(url_for('admin_panel')) @app.route('/admin/category/delete', methods=['POST']) def delete_category(): data = load_data() categories = data.get('categories', []) category_to_delete = request.form.get('category_to_delete') # Имя категории для удаления if not category_to_delete: flash("Не выбрана категория для удаления.", "warning") return redirect(url_for('admin_panel')) if category_to_delete == 'Без категории': flash("Нельзя удалить системную категорию 'Без категории'.", "danger") return redirect(url_for('admin_panel')) # Находим точное имя категории (с учетом регистра) для удаления original_category_name = None category_found = False current_valid_categories = [c for c in categories if isinstance(c, str)] # Работаем только со строками for cat in current_valid_categories: if cat.lower() == category_to_delete.lower(): original_category_name = cat category_found = True break if category_found and original_category_name: current_valid_categories.remove(original_category_name) # Удаляем из списка строк data['categories'] = sorted(current_valid_categories, key=str.lower) # Сохраняем отсортированный список строк # Обновляем материалы, которые принадлежали этой категории materials_updated_count = 0 current_materials = data.get('materials', []) update_time = get_current_time().isoformat() for mat in current_materials: if isinstance(mat, dict) and mat.get('category', 'Без категории') == original_category_name: mat['category'] = 'Без категории' # Переносим в базовую категорию mat['timestamp_last_updated'] = update_time materials_updated_count += 1 # data['materials'] уже обновлен, если были изменения save_data(data) flash(f"Категория '{original_category_name}' успешно удалена.", "success") if materials_updated_count > 0: flash(f"{materials_updated_count} материалов были перенесены в категорию 'Без категории'.", "info") upload_db_to_hf(DATA_FILE) # Бэкап else: flash(f"Категория '{category_to_delete}' не найдена.", "warning") return redirect(url_for('admin_panel')) @app.route('/backup', methods=['POST']) def backup_hf(): """Инициирует ручное резервное копирование обоих файлов на Hugging Face.""" files_uploaded_count = 0 try: logging.info("Запуск ручного резервного копирования на Hugging Face...") # Бэкап основного файла данных with data_lock: # Используем блокировку на всякий случай if os.path.exists(DATA_FILE): upload_db_to_hf(DATA_FILE) files_uploaded_count += 1 else: flash(f"Локальный файл '{DATA_FILE}' не найден для бэкапа.", "warning") # Бэкап файла клиентов with client_data_lock: # Используем блокировку if os.path.exists(CLIENT_DATA_FILE): upload_db_to_hf(CLIENT_DATA_FILE) files_uploaded_count += 1 else: flash(f"Локальный файл '{CLIENT_DATA_FILE}' не найден для бэкапа.", "warning") if files_uploaded_count > 0: flash(f"Резервное копирование {files_uploaded_count} файлов на Hugging Face инициировано.", "success") else: flash("Не найдено локальных файлов для инициации резервного копирования.", "warning") except Exception as e: logging.error(f"Ошибка во время ручного резервного копирования: {e}", exc_info=True) flash(f"Произошла ошибка во время резервного копирования: {e}", "danger") return redirect(url_for('admin_panel')) @app.route('/download', methods=['GET']) def download_hf(): """Скачивает оба файла данных с Hugging Face, перезаписывая локальные.""" downloaded_files = [] errors = [] logging.info("Запуск скачивания данных с Hugging Face...") # Скачивание основного файла данных try: logging.info(f"Попытка скачивания {DATA_FILE}...") hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True) downloaded_files.append(DATA_FILE) logging.info(f"{DATA_FILE} успешно скачан.") except RepositoryNotFoundError: msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." logging.error(msg) errors.append(msg) except HfHubHTTPError as e: if e.response.status_code == 404: msg = f"Файл '{DATA_FILE}' не найден в репозитории '{REPO_ID}'." logging.warning(msg) errors.append(msg) else: msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {DATA_FILE}: {e}" logging.error(msg) errors.append(msg) except Exception as e: msg = f"Неизвестная ошибка при скачивании {DATA_FILE}: {e}" logging.error(msg, exc_info=True) errors.append(msg) # Скачивание файла клиентов (даже если первый файл не скачался) try: logging.info(f"Попытка скачивания {CLIENT_DATA_FILE}...") hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True) downloaded_files.append(CLIENT_DATA_FILE) logging.info(f"{CLIENT_DATA_FILE} успешно скачан.") except RepositoryNotFoundError: # Ошибка репозитория уже должна была быть залогирована выше if not any(f"Репозиторий '{REPO_ID}' не найден" in err for err in errors): msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." logging.error(msg) errors.append(msg) except HfHubHTTPError as e: if e.response.status_code == 404: msg = f"Файл '{CLIENT_DATA_FILE}' не найден в репозитории '{REPO_ID}'." logging.warning(msg) errors.append(msg) else: msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {CLIENT_DATA_FILE}: {e}" logging.error(msg) errors.append(msg) except Exception as e: msg = f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE}: {e}" logging.error(msg, exc_info=True) errors.append(msg) # Вывод сообщений пользователю if downloaded_files: flash(f"Файлы ({', '.join(downloaded_files)}) успешно скачаны и перезаписаны локально.", "success") if errors: flash("Во время скачивания произошли ошибки: " + "; ".join(errors), "danger") if not downloaded_files and not errors: # Странная ситуация, возможно, нет файлов или другая проблема flash("Не удалось инициировать скачивание файлов.", "warning") # Перезагрузка данных в память после скачивания try: logging.info("Перезагрузка данных в память после скачивания...") load_data() load_client_data() logging.info("Данные в памяти обновлены.") except Exception as e: logging.error(f"Ошибка при перезагрузке данных после скачивания: {e}", exc_info=True) flash("Внимание: Файлы скачаны, но произошла ошибка при обновлении данных в приложении. Может потребоваться перезапуск.", "warning") return redirect(url_for('admin_panel')) # 8. Маршрут "Отчеты" @app.route('/reports', methods=['GET']) def reports(): # Загружаем основные данные и конфиг data = load_data() config = data.get('config', {}) now = get_current_time() # Текущее время в Бишкеке # Получение параметров фильтрации из URL filter_type = request.args.get('filter', 'month') # По умолчанию - текущий месяц start_date_str = request.args.get('start_date') end_date_str = request.args.get('end_date') date_str = request.args.get('date') month_str = request.args.get('month') year_str = request.args.get('year') start_date_dt = None end_date_dt = None try: # --- Определение временного диапазона --- if filter_type == 'custom' and start_date_str and end_date_str: sd = datetime.strptime(start_date_str, '%Y-%m-%d') ed = datetime.strptime(end_date_str, '%Y-%m-%d') start_date_dt = BISHKEK_TZ.localize(sd.replace(hour=0, minute=0, second=0, microsecond=0)) end_date_dt = BISHKEK_TZ.localize(ed.replace(hour=23, minute=59, second=59, microsecond=999999)) elif filter_type == 'day': day_to_use_str = date_str if date_str else now.strftime('%Y-%m-%d') d = datetime.strptime(day_to_use_str, '%Y-%m-%d') start_date_dt = BISHKEK_TZ.localize(d.replace(hour=0, minute=0, second=0, microsecond=0)) end_date_dt = start_date_dt.replace(hour=23, minute=59, second=59, microsecond=999999) elif filter_type == 'week': today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) start_date_dt = today_start - timedelta(days=today_start.weekday()) end_date_dt = start_date_dt + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) elif filter_type == 'year': year_to_use_str = year_str if year_str else str(now.year) year_int = int(year_to_use_str) start_date_dt = BISHKEK_TZ.localize(datetime(year_int, 1, 1, 0, 0, 0)) end_date_dt = BISHKEK_TZ.localize(datetime(year_int, 12, 31, 23, 59, 59, 999999)) else: # По умолчанию 'month' month_to_use_str = month_str if month_str else now.strftime('%Y-%m') year, month = map(int, month_to_use_str.split('-')) start_date_dt = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0)) next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) if not start_date_dt or not end_date_dt or start_date_dt > end_date_dt: raise ValueError("Некорректный временной диапазон.") except (ValueError, TypeError) as e: flash(f"Ошибка в задании периода: {e}. Отображен отчет за текущий месяц.", "warning") filter_type = 'month' start_date_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) # --- Фильтрация данных по дате --- filtered_packed_items = [] all_packed_items_raw = data.get('qc_packing_items', []) for item_raw in all_packed_items_raw: if isinstance(item_raw, dict) and 'id' in item_raw: item_data = find_item_by_id(item_raw['id'], 'qc_packing_items') if not item_data: continue packed_time = parse_iso_datetime(item_data.get('timestamp_packed')) if packed_time and start_date_dt <= packed_time <= end_date_dt: shipment_time = None shipment_details = item_data.get('shipment_details') if shipment_details and shipment_details.get('timestamp'): shipment_time = parse_iso_datetime(shipment_details.get('timestamp')) item_data['shipment_time_dt'] = shipment_time filtered_packed_items.append(item_data) all_defect_log_raw = data.get('defect_log', []) filtered_defects = [] for defect_raw in all_defect_log_raw: if isinstance(defect_raw, dict) and 'log_id' in defect_raw: defect_data = find_item_by_id(defect_raw['log_id'], 'defect_log') if not defect_data: continue defect_time = parse_iso_datetime(defect_data.get('timestamp')) if defect_time and start_date_dt <= defect_time <= end_date_dt: filtered_defects.append(defect_data) all_expenses_raw = data.get('expenses', []) filtered_expenses = [] for expense_raw in all_expenses_raw: if isinstance(expense_raw, dict) and 'id' in expense_raw: expense_data = find_item_by_id(expense_raw['id'], 'expenses') if not expense_data: continue expense_time = parse_iso_datetime(expense_data.get('timestamp')) if expense_time and start_date_dt <= expense_time <= end_date_dt: filtered_expenses.append(expense_data) # --- Расчет итоговых показателей за период --- total_packed_quantity = sum(item.get('quantity', 0) for item in filtered_packed_items) total_revenue = sum(item.get('packed_final_price', Decimal('0')) for item in filtered_packed_items) total_material_cost_packed = sum(item.get('packed_material_cost', Decimal('0')) for item in filtered_packed_items) # Общая ЗП из упакованных товаров (уже включает все 3 этапа) total_salary_cost_packed = sum(item.get('packed_salary_cost', Decimal('0')) for item in filtered_packed_items) total_cost_packed = total_material_cost_packed + total_salary_cost_packed total_defect_cost = sum(defect.get('cost_dec', Decimal('0')) for defect in filtered_defects) total_expenses_cost = sum(expense.get('amount', Decimal('0')) for expense in filtered_expenses) total_overall_cost = total_cost_packed + total_defect_cost + total_expenses_cost total_profit = total_revenue - total_overall_cost # --- Расчет детализации ЗП по этапам за период --- total_cutter_salary = Decimal('0') total_sewer_salary = Decimal('0') total_packer_salary = Decimal('0') cutter_rate = to_decimal(config.get('salary_cutter_per_unit', '0')) sewer_rate = to_decimal(config.get('salary_sewer_per_unit', '0')) packer_rate = to_decimal(config.get('salary_packer_per_unit', '0')) for item in filtered_packed_items: qty = item.get('quantity', 0) if qty > 0: # Расчет ЗП основан на количестве упакованных в этот период # Получаем связанные задачи, чтобы понять, сколько было раскроено/сшито ИЗНАЧАЛЬНО # Это не совсем точно, если задача шилась/кроилась в другом периоде, # но для простоты считаем ЗП по упакованным в этот период по текущим ставкам. total_cutter_salary += Decimal(qty) * cutter_rate total_sewer_salary += Decimal(qty) * sewer_rate total_packer_salary += Decimal(qty) * packer_rate calculated_total_salary = total_cutter_salary + total_sewer_salary + total_packer_salary if total_packed_quantity > 0 and abs(calculated_total_salary - total_salary_cost_packed) > Decimal('0.01') * total_packed_quantity : logging.warning(f"Расчетная детализация ЗП ({calculated_total_salary}) не совпадает с общей ЗП из упаковок ({total_salary_cost_packed}). Возможны расхождения в логике или округлении.") # --- Сводка по продуктам --- production_summary = {} for item in filtered_packed_items: product_name = item.get('product_name', 'Неизвестный продукт') quantity = item.get('quantity', 0) revenue = item.get('packed_final_price', Decimal('0')) cost = item.get('packed_total_cost', Decimal('0')) # Используем общую себестоимость партии profit = revenue - cost if product_name not in production_summary: production_summary[product_name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')} production_summary[product_name]['quantity'] += quantity production_summary[product_name]['revenue'] += revenue production_summary[product_name]['cost'] += cost production_summary[product_name]['profit'] += profit # --- Подготовка данных для шаблона --- report_data = { 'total_packed_qty': total_packed_quantity, 'total_revenue': total_revenue, 'total_material_cost': total_material_cost_packed, 'total_salary_cost': total_salary_cost_packed, # Общая ЗП 'total_cost_packed': total_cost_packed, 'total_defect_cost': total_defect_cost, 'total_expenses': total_expenses_cost, 'total_overall_cost': total_overall_cost, 'total_profit': total_profit, # Добавлена детализация ЗП 'total_cutter_salary': total_cutter_salary, 'total_sewer_salary': total_sewer_salary, 'total_packer_salary': total_packer_salary, # --- 'production_summary': production_summary, 'filtered_packed_items': filtered_packed_items, 'filtered_defects': filtered_defects, 'filtered_expenses': filtered_expenses, 'start_date': start_date_dt.strftime('%Y-%m-%d'), 'end_date': end_date_dt.strftime('%Y-%m-%d'), 'filter_type': filter_type, 'current_day': now.strftime('%Y-%m-%d'), 'current_month': now.strftime('%Y-%m'), 'current_year': now.year, 'filter_values': request.args } html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS) return render_template_string(html, report=report_data) # Передаем весь словарь report_data # 9. Маршрут "Облако" ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx'} def allowed_file(filename): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @app.route('/cloud', methods=['GET', 'POST']) def cloud_storage(): data = load_data() cloud_files = data.get('cloud_files', []) if request.method == 'POST': description = request.form.get('description', '').strip() if 'file' not in request.files: flash('Файл не был выбран.', 'warning') return redirect(url_for('cloud_storage')) file = request.files['file'] if file.filename == '': flash('Файл не был выбран.', 'warning') return redirect(url_for('cloud_storage')) if file and allowed_file(file.filename): original_filename = secure_filename(file.filename) # Генерируем уникальное имя файла для хранения file_ext = original_filename.rsplit('.', 1)[1].lower() stored_filename = f"{uuid.uuid4().hex}.{file_ext}" file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) thumbnail_filename = None try: file.save(file_path) logging.info(f"Файл '{original_filename}' сохранен как '{stored_filename}'") # Создаем миниатюру, если это изображение if file_ext in {'png', 'jpg', 'jpeg', 'gif'}: thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], stored_filename) thumbnail_filename = create_thumbnail(file_path, thumb_path) # Вернет имя файла или None # Сохраняем метаданные file_meta = { 'file_id': uuid.uuid4().hex, # ID для управления записью 'original_filename': original_filename, 'stored_filename': stored_filename, 'thumbnail_filename': thumbnail_filename, # Имя файла миниатюры или None 'description': description, 'timestamp': get_current_time().isoformat(), 'size': os.path.getsize(file_path) # Размер файла в байтах } cloud_files.append(file_meta) data['cloud_files'] = cloud_files save_data(data) flash(f"Файл '{original_filename}' успешно загружен.", 'success') upload_db_to_hf(DATA_FILE) # Бэкап except Exception as e: logging.error(f"Ошибка при сохранении файла или создании миниатюры: {e}", exc_info=True) flash(f"Ошибка при загрузке файла: {e}", 'danger') # Удаляем частично загруженный файл, если он есть if os.path.exists(file_path): try: os.remove(file_path) except OSError: pass return redirect(url_for('cloud_storage')) else: flash('Недопустимый тип файла.', 'danger') return redirect(url_for('cloud_storage')) # GET запрос search_query = request.args.get('search', '').lower() if search_query: filtered_files = [ f for f in cloud_files if search_query in f.get('description', '').lower() or \ search_query in f.get('original_filename', '').lower() ] else: filtered_files = cloud_files # Сортируем по дате добавления (сначала новые) filtered_files.sort(key=lambda x: x.get('timestamp', ''), reverse=True) # ИСПРАВЛЕНО: Используем CLOUD_CONTENT html = BASE_TEMPLATE.replace('__TITLE__', "Облачное хранилище").replace('__CONTENT__', CLOUD_CONTENT).replace('__SCRIPTS__', CLOUD_SCRIPTS) return render_template_string(html, files=filtered_files, search_query=search_query) # 10. Маршрут для скачивания файла из облака @app.route('/download_file/') def download_file(filename): # Важно: НЕ используйте secure_filename здесь, т.к. имя файла уже уникально # и может не совпадать с оригинальным! try: # Используем send_from_directory для безопасной отправки файла return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True) except FileNotFoundError: flash("Файл не найден.", "danger") return redirect(url_for('cloud_storage')) except Exception as e: logging.error(f"Ошибка при скачивании файла {filename}: {e}", exc_info=True) flash(f"Ошибка при скачивании файла: {e}", "danger") return redirect(url_for('cloud_storage')) # 11. Маршрут для отображения миниатюр @app.route('/thumbnail/') def get_thumbnail(filename): try: return send_from_directory(app.config['THUMBNAIL_FOLDER'], filename) except FileNotFoundError: # Можно вернуть заглушку или 404 # Убедитесь, что у вас есть файл 'static/placeholder.png' или измените путь # Для простоты вернем 404, если заглушки нет return "Thumbnail not found", 404 # try: # return send_from_directory('static', 'placeholder.png') # except: # Если и заглушка не найдена # return "Placeholder not found", 404 except Exception as e: logging.error(f"Ошибка при отдаче миниатюры {filename}: {e}") return "Error getting thumbnail", 500 # try: # return send_from_directory('static', 'placeholder.png') # except: # return "Placeholder not found", 404 # 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: # Добавлена проверка на dict file_to_delete = f file_index = i break if file_to_delete: stored_filename = file_to_delete.get('stored_filename') thumb_filename = file_to_delete.get('thumbnail_filename') file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) if stored_filename else None thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_filename) if thumb_filename else None try: # Удаляем запись из данных del cloud_files[file_index] data['cloud_files'] = cloud_files save_data(data) # Сохраняем изменения в JSON # Удаляем физический файл if file_path and os.path.exists(file_path): os.remove(file_path) logging.info(f"Удален файл: {file_path}") # Удаляем миниатюру if thumb_path and os.path.exists(thumb_path): os.remove(thumb_path) logging.info(f"Удалена миниатюра: {thumb_path}") flash(f"Файл '{file_to_delete.get('original_filename', 'N/A')}' успешно удален.", 'success') upload_db_to_hf(DATA_FILE) # Бэкап except Exception as e: logging.error(f"Ошибка при удалении файла {file_id}: {e}", exc_info=True) flash(f"Ошибка при удалении файла: {e}", 'danger') # Важно: Не откатываем удаление записи из JSON, если файл не удалился, # чтобы избежать рассинхронизации. Лучше иметь запись без файла, чем файл без записи. else: flash('Файл для удаления не найден.', 'warning') return redirect(url_for('cloud_storage')) # --- HTML Шаблоны --- # Контент заказов ORDERS_CONTENT = """
Создание заказа
Фурнитура:

Список заказов
{% for order in orders %} {% else %} {% endfor %}
ID Клиент Модель Ткань Кол-во ткани Фурнитура Кол-во изделий Размерный ряд Статус Создан Действия
{{ order.id[:8] }}... {{ order.client_name }} {{ order.model_name }} {{ order.fabric_name }} {{ order.fabric_quantity }} {{ order.fabric_unit }}
    {% for f in order.fittings %}
  • {{ f.fitting_name }}: {{ f.quantity }} шт.
  • {% endfor %}
{{ order.items_quantity }} {{ order.size_range }} {{ getStatusText(order.status) }} {{ order.timestamp_created[:16]|replace('T', ' ') }} {% if order.status != 'completed' %}
{% endif %}
Заказы отсутствуют.
""" # Скрипты заказов ORDERS_SCRIPTS = """ """ # ОБЫЧНЫЙ Базовый шаблон (с навигацией) - ИЗМЕНЕНЫ СТИЛИ ВКЛАДОК BASE_TEMPLATE = """ __TITLE__ - КШП {# Title Changed #}
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
{% for category, message in messages %} {% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %} {% endfor %}
{% endif %} {% endwith %} __CONTENT__

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

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

__TITLE__

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

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

{# Title Changed #}
__SCRIPTS__ """ # Контент закупки PROCUREMENT_CONTENT = """
Активные заказы
{% for order in orders %} {% if order.status == 'pending_procurement' and not order.is_procured %} {% endif %} {% else %} {% endfor %}
ID Клиент Изделие Ткань Кол-во ткани Кол-во изделий Размерный ряд Фурнитура Создан Действия
{{ order.id[:8] }}... {{ order.client_name }} {{ order.model_name }} {{ order.fabric_name }} {{ order.fabric_quantity }} {{ order.items_quantity }} {{ order.size_range }} {% if order.fittings %}
    {% for f in order.fittings %}
  • {{ f.fitting_name }}: {{ f.quantity }} шт.
  • {% endfor %}
{% else %} Нет фурнитуры {% endif %}
{{ order.timestamp_created[:16]|replace('T', ' ') }}
Нет активных заказов.
Добавить закупленные материалы
{# Уменьшил ширину категории #}
""" # Скрипты закупки PROCUREMENT_SCRIPTS = """ """ # Контент раскроя CUTTING_CONTENT = """
Регистрация раскроя
""" # Скрипты раскроя CUTTING_SCRIPTS = """ """ # --- ИЗМЕНЕН: Контент пошива --- SEWING_CONTENT = """
Регистрация пошива
{% if cutting_tasks %}
{# Выбор задания на раскрой и Название изделия #}
{# Детали задания на раскрой (БЕЗ ЦЕН/ЗП) #} {# Количество сшитых #}
Не больше, чем было раскроено (0).

{# --- НАЧАЛО ДОБАВЛЕНИЯ: Добавление фурнитуры --- #}
Добавить фурнитуру для ЭТОГО пошива: (Опционально)
{# Количество фурнитуры - целое число #}
{# --- КОНЕЦ ДОБАВЛЕНИЯ --- #}
{# Регистрация брака #}
Регистрация брака материалов (если есть): (Опционально)

{% else %} {% endif %}
""" # --- ИЗМЕНЕН: Скрипты пошива --- SEWING_SCRIPTS = """ """ # Контент ОТК QC_PACKING_CONTENT = """
ОТК и Упаковка готовых изделий
{% if sewing_tasks %}
{% else %} {% endif %}
""" # Скрипты ОТК QC_PACKING_SCRIPTS = """ """ # Контент Базы Клиентов CLIENTS_CONTENT = """

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

Добавить нового клиента
Список клиентов
{# Добавлен table-bordered #} {% for client in clients %} {# Цикл по клиентам #} {% else %} {% endfor %} {# Конец цикла по клиентам #} {# Строка для "не найдено" #}
ID Имя / Организация Телефон Адрес Действия
{{ client.id[:8] }}... {{ client.name }} {{ client.phone }} {{ client.address | default('-') | safe }}
{% if not client.history %}
{% 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 %}
Дополнительные расходы

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

Отчеты

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

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

Выручка

{{ format_currency_py(report.total_revenue) }}

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

{{ format_currency_py(report.total_profit) }}

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

{{ format_currency_py(report.total_overall_cost) }}

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

{{ format_currency_py(report.total_defect_cost) }}

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

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

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

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

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

{{ format_currency_py(report.total_expenses) }}

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

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

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

{{ file.description }}

{% endif %}

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

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

{% endfor %}
{% else %} {% endif %}
""" # Скрипты Облака CLOUD_SCRIPTS = """ """ # Контент Авансов ADVANCES_CONTENT = """
Выдача аванса
История авансов
{% for advance in advances %} {% else %} {% 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 advance.is_processed %}Вычтено из ЗП{% else %}Ожидает вычета{% 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 } if __name__ == '__main__': # Запускаем поток для периодического бэкапа backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() try: logging.info("Первоначальная загрузка данных...") load_data() load_client_data() logging.info("Данные успешно загружены/инициализированы.") except Exception as e: logging.critical(f"Не удалось загрузить базы данных при запуске: {e}", exc_info=True) logging.info("Запуск Flask приложения на http://0.0.0.0:7860") app.run(debug=True, host='0.0.0.0', port=7860, use_reloader=False)