diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -6,12 +6,13 @@ import os import logging import threading import time -from datetime import datetime +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 # Для точной работы с метрами/сантиметрами +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP # Для точной работы с деньгами и метрами # --- Настройки приложения --- app = Flask(__name__) @@ -23,11 +24,12 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # --- Настройки Hugging Face --- # !!! ВАЖНО: Установите переменные окружения HF_TOKEN_WRITE и HF_TOKEN_READ !!! -# Создайте файл .env или установите системные переменные HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE") # Замените или установите переменную окружения HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE") # Замените или установите переменную окружения REPO_ID = "Kgshop/cech" # !!! Обновленный REPO_ID !!! -# Убедитесь, что репозиторий существует на Hugging Face как Dataset + +# --- Часовой пояс --- +BISHKEK_TZ = pytz.timezone('Asia/Bishkek') # --- Настройка логирования --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -37,11 +39,14 @@ data_lock = threading.Lock() # --- Вспомогательные функции для работы с данными --- +def get_current_time(): + """Возвращает текущее время в Бишкекском часовом поясе.""" + return datetime.now(BISHKEK_TZ) + def load_data(): """Загружает данные из JSON файла, скачивая с Hugging Face при необходимости.""" with data_lock: try: - # Пытаемся скачать последнюю версию с HF logging.info(f"Попытка скачивания {DATA_FILE} из репозитория {REPO_ID}...") hf_hub_download( repo_id=REPO_ID, @@ -50,7 +55,7 @@ def load_data(): token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - force_download=True # Всегда скачиваем последнюю версию + force_download=True ) logging.info("База данных успешно скачана из Hugging Face.") except RepositoryNotFoundError: @@ -63,27 +68,32 @@ def load_data(): except Exception as e: logging.error(f"Неизвестная ошибка при скачивании из Hugging Face: {e}") - # Читаем локальный файл (скачанный или существующий) try: with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) logging.info("Данные успешно загружены из локального JSON.") - # Проверка базовой структуры if not isinstance(data, dict): logging.warning("Файл данных не является словарем, инициализация пустой структурой.") return initialize_data_structure() - # Убедимся, что все ключи существуют + # Инициализация недостающих ключей верхнего уровня default_data = initialize_data_structure() for key in default_data.keys(): if key not in data: data[key] = default_data[key] + # Дополнительно проверяем config + if 'config' not in data or not isinstance(data['config'], dict): + data['config'] = default_data['config'] + else: + # Проверяем наличие ключей внутри config + for config_key, default_value in default_data['config'].items(): + if config_key not in data['config']: + data['config'][config_key] = default_value return data except FileNotFoundError: logging.warning(f"Локальный файл {DATA_FILE} не найден. Инициализация пустой структурой.") return initialize_data_structure() except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в файле {DATA_FILE}. Инициализация пустой структурой.") - # Можно добавить логику бэкапа поврежденного файла здесь return initialize_data_structure() except Exception as e: logging.error(f"Неизвестная ошибка при загрузке локальных данных: {e}") @@ -93,20 +103,14 @@ def save_data(data): """Сохраняет данные в JSON файл и загружает на Hugging Face.""" with data_lock: try: - # Сначала сохраняем локально temp_file = DATA_FILE + ".tmp" with open(temp_file, 'w', encoding='utf-8') as file: - # Используем собственный сериализатор для Decimal json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder) - os.replace(temp_file, DATA_FILE) # Атомарная замена файла + os.replace(temp_file, DATA_FILE) logging.info(f"Данные успешно сохранены в локальный файл {DATA_FILE}.") - - # Затем загружаем на Hugging Face upload_db_to_hf() - except Exception as e: logging.error(f"Критическая ошибка при сохранении данных: {e}") - # Можно добавить логику отката или повторной попытки if os.path.exists(temp_file): try: os.remove(temp_file) @@ -118,10 +122,17 @@ def initialize_data_structure(): return { 'materials': [], # Закупленные материалы (ткани, фурнитура) 'categories': [], # Список категорий материалов - 'cutting_tasks': [], # Задания на раскрой (что вырезано, сколько) - 'sewing_tasks': [], # Задания на пошив (что сшито, из чего) - 'qc_packing_items': [], # Готовые и упакованные изделия - 'defect_log': [] # Журнал брака + 'cutting_tasks': [], # Задания на раскрой (что вырезано, сколько, стоимость) + 'sewing_tasks': [], # Задания на пошив (что сшито, из чего, стоимость) + 'qc_packing_items': [], # Готовые и упакованные изделия (стоимость, цена) + 'defect_log': [], # Журнал брака + 'expenses': [], # Дополнительные расходы + 'config': { # Настройки зарплат и маржи + 'salary_cutter_per_unit': '0.00', + 'salary_sewer_per_unit': '0.00', + 'salary_packer_per_unit': '0.00', + 'margin_per_item': '0.00' + } } def upload_db_to_hf(): @@ -131,17 +142,18 @@ def upload_db_to_hf(): return try: api = HfApi() + commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') api.upload_file( path_or_fileobj=DATA_FILE, - path_in_repo=DATA_FILE, # Имя файла в репозитории + path_in_repo=DATA_FILE, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, - commit_message=f"Автоматическое резервное копирование базы данных {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + commit_message=f"Автоматическое резервное копирование базы данных {commit_time}" ) logging.info(f"Резервная копия {DATA_FILE} успешно загружена на Hugging Face в репозиторий {REPO_ID}.") except RepositoryNotFoundError: - logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face. Убедитесь, что он создан.") + logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face.") except Exception as e: logging.error(f"Ошибка при загрузке резервной копии на Hugging Face: {e}") @@ -151,91 +163,95 @@ def periodic_backup(): while True: time.sleep(1800) # Каждые 30 минут logging.info("Запуск планового резервного копирования...") - with data_lock: # Блокируем на время чтения файла для загрузки + with data_lock: upload_db_to_hf() -# --- Сериализатор для Decimal --- +# --- Сериализатор/Десериализатор для Decimal --- class DecimalEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, Decimal): - # Сохраняем как строку для точности return str(obj) - # Пусть базовый класс обработает остальные типы return json.JSONEncoder.default(self, obj) -# --- Преобразователь для Decimal в маршрутах --- -def to_decimal(value_str): +def to_decimal(value_str, default='0.00'): """Безопасно преобразует строку в Decimal.""" - if not value_str: - return Decimal('0.00') + if value_str is None or value_str == '': + return Decimal(default) try: - # Заменяем запятую на точку для универсальности ввода - return Decimal(value_str.replace(',', '.')) + # Убедимся, что это строка перед заменой + s = str(value_str).replace(',', '.') + return Decimal(s) except InvalidOperation: - logging.warning(f"Не удалось преобразовать '{value_str}' в Decimal. Возвращено 0.") - return Decimal('0.00') - -# --- Вспомогательные функции для поиска --- -def find_material_by_id(material_id): - """Находит материал по ID.""" - data = load_data() - for material in data.get('materials', []): - if material.get('id') == material_id: - # Преобразуем количество обратно в Decimal при чтении - material['quantity'] = to_decimal(material.get('quantity', '0')) - return material - return None - -def find_cutting_task_by_id(task_id): - """Находит задание на раскрой по ID.""" - data = load_data() - for task in data.get('cutting_tasks', []): - if task.get('id') == task_id: - # Преобразуем числовые поля обратно в Decimal/int - task['cut_items_quantity'] = int(task.get('cut_items_quantity', 0)) - task['fabric_used'] = to_decimal(task.get('fabric_used', '0')) - if 'required_fittings' in task: - for fitting in task['required_fittings']: - fitting['quantity_needed'] = int(fitting.get('quantity_needed', 0)) - return task - return None - -def find_sewing_task_by_id(task_id): - """Находит задание на пошив по ID.""" + 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: + dt = datetime.fromisoformat(timestamp_str) + # Если объект наивный (нет tzinfo), считаем его UTC и конвертируем в Бишкек + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + return pytz.utc.localize(dt).astimezone(BISHKEK_TZ) + # Если уже с поясом, просто убедимся, что это Бишкек + return dt.astimezone(BISHKEK_TZ) + except (ValueError, TypeError): + logging.warning(f"Не удалось разобрать дату: {timestamp_str}") + return None + +def find_item_by_id(item_id, item_list_name): + """Обобщенная функция для поиска элемента по ID в списке данных.""" data = load_data() - for task in data.get('sewing_tasks', []): - if task.get('id') == task_id: - # Преобразуем числовые поля обратно в Decimal/int - task['sewn_quantity'] = int(task.get('sewn_quantity', 0)) - if 'fittings_consumed' in task: - for fitting in task['fittings_consumed']: - fitting['quantity_used'] = int(fitting.get('quantity_used', 0)) - if 'defects_reported' in task: # Исправлено с defects на defects_reported - for defect in task['defects_reported']: - # Проверяем тип перед преобразованием - defect_qty_val = defect.get('quantity', '0') - if defect.get('type') == 'fabric': - defect['quantity'] = to_decimal(defect_qty_val) - elif defect.get('type') == 'fittings': - try: - defect['quantity'] = int(Decimal(defect_qty_val)) # Сначала в Decimal для обработки строки, потом в int - except (InvalidOperation, ValueError): - defect['quantity'] = 0 # или другое значение по умолчанию - logging.warning(f"Не удалось преобразовать количество брака фурнитуры '{defect_qty_val}' в int для task {task.get('id')}") - elif defect.get('type') == 'finished_product': - try: - defect['quantity'] = int(Decimal(defect_qty_val)) - except (InvalidOperation, ValueError): - defect['quantity'] = 0 - logging.warning(f"Не удалось преобразовать количество брака готового продукта '{defect_qty_val}' в int для task {task.get('id')}") - else: # На случай других типов или отсутствия - defect['quantity'] = 0 # Безопасное значение по умолчанию - - # Добавим обработку полей qc_packed_quantity и qc_defective_quantity, если они есть - task['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) - task['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) - - return task + items = data.get(item_list_name, []) + for item in items: + if item.get('id') == item_id: + # Базовое преобразование ID и названий + item_copy = item.copy() + # Преобразование Decimal полей (нужно знать их имена) + decimal_fields = [] + int_fields = [] + 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'] + # Обработка вложенных списков + if 'required_fittings' in item_copy: + for fitting in item_copy['required_fittings']: + fitting['quantity_needed'] = int(fitting.get('quantity_needed', 0)) + elif item_list_name == 'sewing_tasks': + decimal_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost'] # cutting_salary_cost копируется + int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] + # Обработка вложенных списков + if 'fittings_consumed' in item_copy: + for fitting in item_copy['fittings_consumed']: + fitting['quantity_used'] = int(fitting.get('quantity_used', 0)) + if 'defects_reported' in item_copy: + for defect in item_copy['defects_reported']: + defect_qty_val = defect.get('quantity', '0') + if defect.get('type') == 'fabric': + defect['quantity'] = to_decimal(defect_qty_val) + elif defect.get('type') in ['fittings', 'finished_product']: + try: + defect['quantity'] = int(to_decimal(defect_qty_val)) + except (InvalidOperation, ValueError): defect['quantity'] = 0 + else: defect['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'] + int_fields = [] # Нет целых чисел пока + + for field in decimal_fields: + item_copy[field] = to_decimal(item_copy.get(field)) # Используем '0.00' по умолчанию + for field in int_fields: + item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) # Безопасное преобразование в int + + return item_copy return None # --- Маршруты Flask --- @@ -248,110 +264,103 @@ def index(): # 1. Маршрут "Закуп" @app.route('/procurement', methods=['GET', 'POST']) def procurement(): - """Страница для добавления закупленных материалов.""" data = load_data() categories = data.get('categories', []) if request.method == 'POST': try: materials_to_add = [] - # Обрабатываем каждую строку товара из формы item_names = request.form.getlist('item_name[]') item_quantities = request.form.getlist('item_quantity[]') item_units = request.form.getlist('item_unit[]') + item_prices_per_unit = request.form.getlist('item_price_per_unit[]') # Новое поле цены item_per_unit_list = request.form.getlist('item_per_unit[]') item_types = request.form.getlist('item_type[]') item_categories = request.form.getlist('item_category[]') - new_categories = request.form.getlist('item_new_category[]') # Поле для новой категории + new_categories = request.form.getlist('item_new_category[]') if not item_names: flash("Не добавлено ни одного товара.", "warning") return redirect(url_for('procurement')) - procurement_timestamp = datetime.now().isoformat() # Единое время для всей закупки + procurement_timestamp = get_current_time().isoformat() 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_per_unit[i] # Получаем цену per_unit_str = item_per_unit_list[i] - item_type = item_types[i] # 'fabric' или 'fittings' + item_type = item_types[i] category = item_categories[i] new_category_name = new_categories[i].strip() - if not name or not quantity_str or not unit or not item_type: - flash(f"Ошибка в строке {i+1}: Заполните название, количество, единицу и тип.", "danger") - 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 quantity = to_decimal(quantity_str) + price_per_unit = to_decimal(price_str) # Преобразуем цену + if quantity <= 0: flash(f"Ошибка в строке {i+1}: Количество должно быть положительным.", "danger") continue + if price_per_unit < 0: # Цена может быть 0, но не отрицательной + flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger") + continue - # Обработка 'рассчитано на единиц' items_per_unit = 0 if per_unit_str: - try: - items_per_unit = int(per_unit_str) - if items_per_unit < 0: items_per_unit = 0 # Не может быть отрицательным - except ValueError: - items_per_unit = 0 - flash(f"Предупреждение в строке {i+1}: Некорректное значение 'рассчитано на', установлено в 0.", "warning") + try: items_per_unit = int(per_unit_str) + except ValueError: items_per_unit = 0 + if items_per_unit < 0: items_per_unit = 0 - # Обработка категории final_category = "Без категории" - if new_category_name: # Приоритет у новой категории + if new_category_name: final_category = new_category_name if final_category not in categories: - categories.append(final_category) # Добавляем новую категорию в общий список + categories.append(final_category) elif category and category != "__new__": final_category = category - # Проверяем, существует ли уже материал с таким именем, типом и категорией - existing_material = None existing_material_index = -1 for idx, mat in enumerate(data.get('materials', [])): - # Добавим .lower() для нечувствительности к регистру имени при поиске дубликата if mat['name'].lower() == name.lower() and mat['type'] == item_type and mat.get('category', 'Без категории') == final_category: - existing_material = mat existing_material_index = idx break - if existing_material and existing_material_index != -1: - # Обновляем количество существующего материала - current_quantity = to_decimal(existing_material.get('quantity', '0')) + if existing_material_index != -1: + current_quantity = to_decimal(data['materials'][existing_material_index].get('quantity', '0')) new_total_quantity = current_quantity + quantity - data['materials'][existing_material_index]['quantity'] = str(new_total_quantity) # Сохраняем как строку - # Обновляем другие поля на всякий случай + data['materials'][existing_material_index]['quantity'] = str(new_total_quantity) data['materials'][existing_material_index]['unit'] = unit + # Обновляем цену и кол-во на ед., если они изменились + data['materials'][existing_material_index]['price_per_unit'] = str(price_per_unit) data['materials'][existing_material_index]['items_per_unit'] = items_per_unit - data['materials'][existing_material_index]['timestamp_last_updated'] = procurement_timestamp # Добавляем время обновления - logging.info(f"Обновлено количество материала '{name}' (ID: {existing_material['id']}) на +{quantity}. Новое количество: {new_total_quantity}") + data['materials'][existing_material_index]['timestamp_last_updated'] = procurement_timestamp + logging.info(f"Обновлен материал '{name}' (ID: {data['materials'][existing_material_index]['id']}). Новое количество: {new_total_quantity}, Цена: {price_per_unit}") else: - # Добавляем новый материал new_material = { - 'id': uuid.uuid4().hex, # Уникальный ID + 'id': uuid.uuid4().hex, 'name': name, - 'quantity': str(quantity), # Сразу сохраняем как строку + 'quantity': str(quantity), 'unit': unit, + 'price_per_unit': str(price_per_unit), # Сохраняем цену 'items_per_unit': items_per_unit, - 'type': item_type, # 'fabric' или 'fittings' + 'type': item_type, 'category': final_category, 'timestamp_added': procurement_timestamp, 'timestamp_last_updated': procurement_timestamp } materials_to_add.append(new_material) - logging.info(f"Подготовлен к добавлению новый материал: {name} ({quantity} {unit})") + logging.info(f"Подготовлен к добавлению новый материал: {name} ({quantity} {unit}, Цена: {price_per_unit})") - # Добавляем все новые материалы в базу if materials_to_add: - if 'materials' not in data: - data['materials'] = [] + if 'materials' not in data: data['materials'] = [] data['materials'].extend(materials_to_add) - # Обновляем список категорий в данных - data['categories'] = sorted(list(set(categories))) # Обновляем и сортируем - + data['categories'] = sorted(list(set(categories)), key=str.lower) save_data(data) flash(f"Закуп успешно зарегистрирован! Добавлено {len(materials_to_add)} новых позиций, обновлены существующие.", "success") return redirect(url_for('procurement')) @@ -362,27 +371,21 @@ def procurement(): # --- GET запрос --- page_title = "Закуп материалов" - page_content = PROCUREMENT_CONTENT # Используем переменную с контентом - page_scripts = PROCUREMENT_SCRIPTS # Используем переменную со скриптами - - # Заменяем маркеры в базовом шаблоне + page_content = PROCUREMENT_CONTENT + page_scripts = PROCUREMENT_SCRIPTS html = BASE_TEMPLATE.replace('__TITLE__', page_title) html = html.replace('__CONTENT__', page_content) html = html.replace('__SCRIPTS__', page_scripts) - - # Передаем нужные переменные в render_template_string для рендеринга Jinja внутри контента return render_template_string(html, categories=categories) # 2. Маршрут "Раскрой" @app.route('/cutting', methods=['GET', 'POST']) def cutting(): - """Страница для регистрации раскроя ткани.""" data = load_data() - # Получаем только ткани для выбора fabrics = [m for m in data.get('materials', []) if m.get('type') == 'fabric' and to_decimal(m.get('quantity', '0')) > 0] - # Получаем фурнитуру для добавления к заданию fittings = [m for m in data.get('materials', []) if m.get('type') == 'fittings' and to_decimal(m.get('quantity', '0')) > 0] + config = data.get('config', initialize_data_structure()['config']) # Получаем конфиг ЗП if request.method == 'POST': try: @@ -396,18 +399,16 @@ def cutting(): flash("Выберите ткань, укажите количество изделий и расход ткани.", "danger") return redirect(url_for('cutting')) - fabric = find_material_by_id(fabric_id) + fabric = find_item_by_id(fabric_id, 'materials') # Используем общую функцию if not fabric: flash("Выбранная ткань не найдена в базе.", "danger") return redirect(url_for('cutting')) try: cut_items_quantity = int(cut_items_quantity_str) - if cut_items_quantity <= 0: - flash("Количество раскроенных изделий должно быть положительным.", "danger") - return redirect(url_for('cutting')) + if cut_items_quantity <= 0: raise ValueError except ValueError: - flash("Некорректное количество раскроенных изделий.", "danger") + flash("Количество раскроенных изделий должно быть положительным целым числом.", "danger") return redirect(url_for('cutting')) fabric_used = to_decimal(fabric_used_str) @@ -415,86 +416,80 @@ def cutting(): flash("Расход ткани должен быть положительным.", "danger") return redirect(url_for('cutting')) - # Проверка наличия достаточного количества ткани - available_fabric = fabric.get('quantity', Decimal('0')) # find_material_by_id уже вернул Decimal - if fabric_used > available_fabric: - flash(f"Недостаточно ткани '{fabric['name']}'. В наличии: {available_fabric} {fabric['unit']}, требуется: {fabric_used} {fabric['unit']}.", "danger") + if fabric_used > fabric.get('quantity', Decimal('0')): + flash(f"Недостаточно ткани '{fabric['name']}'. В наличии: {fabric['quantity']} {fabric['unit']}, требуется: {fabric_used} {fabric['unit']}.", "danger") return redirect(url_for('cutting')) - # Сбор информации о необходимой фурнитуре required_fittings_list = [] - # total_fittings_cost = Decimal('0.00') # Пример расчета стоимости, если нужно if fitting_ids and fitting_quantities and len(fitting_ids) == len(fitting_quantities): for i in range(len(fitting_ids)): fit_id = fitting_ids[i] fit_qty_str = fitting_quantities[i] - if not fit_id or not fit_qty_str: - continue # Пропускаем пустые строки фурнитуры + if not fit_id or not fit_qty_str: continue - fitting_material = find_material_by_id(fit_id) + fitting_material = find_item_by_id(fit_id, 'materials') if not fitting_material: flash(f"Фурнитура с ID {fit_id} не найдена.", "warning") continue - try: fit_qty = int(fit_qty_str) - if fit_qty <= 0: - flash(f"Количество фурнитуры '{fitting_material['name']}' должно быть положительным.", "warning") - continue + if fit_qty <= 0: raise ValueError except ValueError: - flash(f"Некорректное количество для фурнитуры '{fitting_material['name']}'.", "warning") + flash(f"Некорректное количество фурнитуры '{fitting_material['name']}'.", "warning") continue - # Можно добавить проверку наличия фурнитуры здесь, но строже проверять на этапе пошива - available_fitting_qty = fitting_material.get('quantity', Decimal('0')) - if Decimal(fit_qty) > available_fitting_qty: - flash(f"Предупреждение: Недостаточно фурнитуры '{fitting_material['name']}'. В наличии: {available_fitting_qty}, требуется: {fit_qty}.", "warning") - # Не блокируем, просто предупреждаем + # Проверка доступности фурнитуры (предупреждение) + if Decimal(fit_qty) > fitting_material.get('quantity', Decimal('0')): + flash(f"Предупреждение: Недостаточно фурнитуры '{fitting_material['name']}'. В наличии: {fitting_material['quantity']}, требуется: {fit_qty}.", "warning") required_fittings_list.append({ 'fitting_id': fit_id, 'fitting_name': fitting_material['name'], - 'quantity_needed': fit_qty # Сколько нужно на ВСЕ раскроенные изделия + 'quantity_needed': fit_qty # Общее кол-во на все раскроенные }) - # total_fittings_cost += fitting_material.get('price', Decimal('0.00')) * Decimal(fit_qty) # Пример - # Создание задания на раскрой + # Расчет стоимости + fabric_price_per_unit = fabric.get('price_per_unit', Decimal('0.00')) + material_cost = fabric_used * fabric_price_per_unit + salary_cutter = to_decimal(config.get('salary_cutter_per_unit', '0.00')) + cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter + + current_time = get_current_time().isoformat() + new_cutting_task = { 'id': uuid.uuid4().hex, 'fabric_id': fabric_id, 'fabric_name': fabric['name'], 'fabric_unit': fabric['unit'], 'cut_items_quantity': cut_items_quantity, - 'fabric_used': str(fabric_used), # Сохраняем как строку + 'fabric_used': str(fabric_used), 'required_fittings': required_fittings_list, - 'status': 'pending', # 'pending', 'completed' (когда отправлено на пошив) - 'timestamp_created': datetime.now().isoformat(), - 'timestamp_completed': None # Заполнится при создании задания на пошив + 'status': 'pending', + 'timestamp_created': current_time, + 'timestamp_completed': None, + # Новые поля стоимости + 'material_cost': str(material_cost), + 'cutting_salary_cost': str(cutting_salary_cost) } - # Обновление количества ткани в базе - new_fabric_quantity = available_fabric - fabric_used - # Найдем индекс ткани в списке и обновим её + # Обновление количества ткани + new_fabric_quantity = fabric['quantity'] - fabric_used updated = False for i, mat in enumerate(data.get('materials', [])): if mat.get('id') == fabric_id: data['materials'][i]['quantity'] = str(new_fabric_quantity) - data['materials'][i]['timestamp_last_updated'] = new_cutting_task['timestamp_created'] + data['materials'][i]['timestamp_last_updated'] = current_time updated = True break if not updated: - # Этого не должно произойти, если find_material_by_id сработал, но на всякий случай - flash(f"Критическая ошибка: не удалось обновить количество ткани {fabric['name']} в базе данных.", "danger") + flash(f"Критическая ошибка: не удалось обновить количество ткани {fabric['name']}.", "danger") return redirect(url_for('cutting')) - - # Добавление задания в список - if 'cutting_tasks' not in data: - data['cutting_tasks'] = [] + if 'cutting_tasks' not in data: data['cutting_tasks'] = [] data['cutting_tasks'].append(new_cutting_task) save_data(data) - flash(f"Задание на раскрой для {cut_items_quantity} ед. из ткани '{fabric['name']}' успешно создано.", "success") + flash(f"Задание на раскрой ��ля {cut_items_quantity} ед. из ткани '{fabric['name']}' успешно создано (Стоимость ткани: {material_cost:.2f}, ЗП раскроя: {cutting_salary_cost:.2f}).", "success") return redirect(url_for('cutting')) except Exception as e: @@ -502,45 +497,37 @@ def cutting(): flash(f"Произошла внутренняя ошибка при обработке раскроя: {e}", "danger") # --- GET запрос --- - # Передаем материалы, преобразовав количество в Decimal для отображения fabrics_dec = [] for f in fabrics: - f_copy = f.copy() - f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа - fabrics_dec.append(f_copy) + f_copy = find_item_by_id(f['id'], 'materials') # Получаем с преобразованными числами + if f_copy: fabrics_dec.append(f_copy) fittings_dec = [] for f in fittings: - f_copy = f.copy() - f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа - fittings_dec.append(f_copy) + f_copy = find_item_by_id(f['id'], 'materials') + if f_copy: fittings_dec.append(f_copy) page_title = "Раскрой ткани" page_content = CUTTING_CONTENT page_scripts = CUTTING_SCRIPTS - html = BASE_TEMPLATE.replace('__TITLE__', page_title) html = html.replace('__CONTENT__', page_content) html = html.replace('__SCRIPTS__', page_scripts) - - # Передаем переменные в render_template_string return render_template_string(html, fabrics=fabrics_dec, fittings=fittings_dec) # 3. Маршрут "Пошив" @app.route('/sewing', methods=['GET', 'POST']) def sewing(): - """Страница для регистрации пошива изделий.""" data = load_data() - # Получаем задания на раскрой, ожидающие пошива pending_cutting_tasks = [t for t in data.get('cutting_tasks', []) if t.get('status') == 'pending'] + config = data.get('config', initialize_data_structure()['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') - # Данные о браке defect_material_ids = request.form.getlist('defect_material_id[]') defect_quantities = request.form.getlist('defect_quantity[]') @@ -548,138 +535,123 @@ def sewing(): flash("Выберите задание, укажите название изделия и количество сшитых.", "danger") return redirect(url_for('sewing')) - cutting_task = find_cutting_task_by_id(cutting_task_id) # Вернет данные с преобразованными числами + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') if not cutting_task or cutting_task.get('status') != 'pending': - flash("Выбранное задание на раскрой не найдено или уже отправлено на пошив.", "danger") + flash("Выбранное задание на раскрой не найдено или уже обработано.", "danger") return redirect(url_for('sewing')) try: sewn_quantity = int(sewn_quantity_str) cut_items_quantity_task = cutting_task.get('cut_items_quantity', 0) - if sewn_quantity <= 0: - flash("Количество сшитых изделий должно быть положительным.", "danger") - return redirect(url_for('sewing')) - # Нельзя сшить больше, чем было раскроено + if sewn_quantity <= 0: raise ValueError if sewn_quantity > cut_items_quantity_task: flash(f"Нельзя сшить больше изделий ({sewn_quantity}), чем было раскроено ({cut_items_quantity_task}).", "danger") return redirect(url_for('sewing')) - except ValueError: - flash("Некорректное количество сшитых изделий.", "danger") + flash("Количество сшитых изделий должно быть положительным целым числом.", "danger") return redirect(url_for('sewing')) - # Обработка списания фурнитуры и брака fittings_consumed_list = [] defect_log_list = [] - materials_to_update = {} # Словарь {material_id: quantity_to_deduct (Decimal)} - - # 1. Списание необходимой фурнитуры - required_fittings = cutting_task.get('required_fittings', []) + materials_to_update = {} # {material_id: quantity_to_deduct (Decimal)} + total_fittings_cost = Decimal('0.00') can_sew = True - current_time = datetime.now().isoformat() + current_time = get_current_time().isoformat() + # 1. Списание необходимой фурнитуры и расчет ее стоимости + required_fittings = cutting_task.get('required_fittings', []) for req_fitting in required_fittings: fitting_id = req_fitting['fitting_id'] - needed_total_on_cut = req_fitting['quantity_needed'] # Сколько нужно было на весь крой + needed_total_on_cut = req_fitting.get('quantity_needed', 0) # Уже int - # Пропорционально рассчитываем, сколько нужно на фактически сшитое количество - needed_for_this_sewing = Decimal('0') + # Пропорциональный расчет необходимого кол-ва на этот пошив + needed_for_this_sewing = 0 if cut_items_quantity_task > 0: - # Используем Decimal для точности расчета доли - needed_for_this_sewing = (Decimal(needed_total_on_cut) / Decimal(cut_items_quantity_task)) * Decimal(sewn_quantity) - # Округляем до целого, т.к. фурнитура обычно целая - needed_for_this_sewing = int(needed_for_this_sewing.to_integral_value(rounding='ROUND_HALF_UP')) + # Округляем до ближайшего целого + needed_dec = (Decimal(needed_total_on_cut) / Decimal(cut_items_quantity_task)) * Decimal(sewn_quantity) + needed_for_this_sewing = int(needed_dec.to_integral_value(rounding=ROUND_HALF_UP)) - if needed_for_this_sewing <= 0: continue # Если ничего не нужно, пропускаем + if needed_for_this_sewing <= 0: continue - fitting_material = find_material_by_id(fitting_id) + fitting_material = find_item_by_id(fitting_id, 'materials') if not fitting_material: - flash(f"Фурнитура '{req_fitting['fitting_name']}' (ID: {fitting_id}), необходимая для задания, не найдена в базе!", "danger") - can_sew = False - break # Критическая ошибка + flash(f"Фурнитура '{req_fitting.get('fitting_name', 'ID: '+fitting_id)}' не найдена!", "danger") + can_sew = False; break - available_fitting = fitting_material.get('quantity', Decimal('0')) # Already Decimal + available_fitting = fitting_material.get('quantity', Decimal('0')) if available_fitting < Decimal(needed_for_this_sewing): flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. В наличии: {available_fitting}, требуется: {needed_for_this_sewing}.", "danger") - can_sew = False - break # Не можем шить без фурнитуры + can_sew = False; break - # Добавляем в список для списания (используем Decimal для внутреннего учета) materials_to_update[fitting_id] = materials_to_update.get(fitting_id, Decimal('0')) + Decimal(needed_for_this_sewing) + fitting_price = fitting_material.get('price_per_unit', Decimal('0.00')) + total_fittings_cost += fitting_price * Decimal(needed_for_this_sewing) + fittings_consumed_list.append({ 'fitting_id': fitting_id, 'fitting_name': fitting_material['name'], - 'quantity_used': needed_for_this_sewing # Сколько ушло именно на этот пошив + 'quantity_used': needed_for_this_sewing, # Кол-во на этот пошив + 'cost': str(fitting_price * Decimal(needed_for_this_sewing)) # Стоимость этой фурнитуры для этого пошива }) - if not can_sew: - return redirect(url_for('sewing')) # Прерываем, если чего-то не хватает + if not can_sew: return redirect(url_for('sewing')) - # 2. Обработка брака + # 2. Обработка брака (как раньше, но учитываем materials_to_update) if defect_material_ids and defect_quantities and len(defect_material_ids) == len(defect_quantities): for i in range(len(defect_material_ids)): def_mat_id = defect_material_ids[i] def_qty_str = defect_quantities[i] - if not def_mat_id or not def_qty_str: continue - defect_material = find_material_by_id(def_mat_id) # Already Decimal quantity + defect_material = find_item_by_id(def_mat_id, 'materials') if not defect_material: - flash(f"Материал для брака с ID {def_mat_id} не найден.", "warning") - continue + flash(f"Материал для брака ID {def_mat_id} не найден.", "warning"); continue try: - # Количество брака может быть дробным для ткани + def_qty = Decimal('0') if defect_material['type'] == 'fabric': - def_qty = to_decimal(def_qty_str) # Преобразуем введенное значение - else: # Целое для фурнитуры - def_qty = Decimal(int(to_decimal(def_qty_str))) # Преобразуем и округляем до целого Decimal + def_qty = to_decimal(def_qty_str) + else: # fittings + def_qty = Decimal(int(to_decimal(def_qty_str))) # Целое число - if def_qty <= 0: - flash(f"Количество брака для '{defect_material['name']}' должно быть положительным.", "warning") - continue + if def_qty <= 0: raise ValueError except (ValueError, InvalidOperation): flash(f"Некорректное количество брака для '{defect_material['name']}'.", "warning") continue available_defect_material = defect_material.get('quantity', Decimal('0')) - - # Учитываем уже запланированное к списанию количество этого материала already_planned_deduction = materials_to_update.get(def_mat_id, Decimal('0')) effective_available = available_defect_material - already_planned_deduction if effective_available < def_qty: flash(f"Недостаточно материала '{defect_material['name']}' для списания в брак ({def_qty} {defect_material['unit']}). Доступно с учетом расхода: {effective_available}.", "danger") - can_sew = False # Не можем списать в брак больше, чем есть - break + can_sew = False; break - # Добавляем количество брака к общему количеству для списания этого материала materials_to_update[def_mat_id] = materials_to_update.get(def_mat_id, Decimal('0')) + def_qty # Добавляем в лог брака - unit_str = defect_material['unit'] - final_defect_qty_for_log = def_qty # Decimal для ткани - if defect_material['type'] == 'fittings': - final_defect_qty_for_log = int(def_qty) # Int для фурнитуры в логе + final_defect_qty_for_log = def_qty if defect_material['type'] == 'fabric' else int(def_qty) + defect_price = defect_material.get('price_per_unit', Decimal('0.00')) + defect_cost = defect_price * def_qty # Стоимость брака defect_log_entry = { 'log_id': uuid.uuid4().hex, 'material_id': def_mat_id, 'material_name': defect_material['name'], - 'quantity': final_defect_qty_for_log, # Сохраняем соответствующий тип - 'unit': unit_str, + 'quantity': final_defect_qty_for_log, # Правильный тип + 'unit': defect_material['unit'], 'type': defect_material['type'], 'stage': 'sewing', - 'reason': 'Брак при пошиве', # Можно добавить поле для причины - 'sewing_task_id': None, # Добавим ID после создания задания на пошив + 'reason': 'Брак при пошиве', + 'cost': str(defect_cost), # Стоимость брака + 'sewing_task_id': None, # Будет ниже 'timestamp': current_time } defect_log_list.append(defect_log_entry) - if not can_sew: - return redirect(url_for('sewing')) # Прерываем, если не хватило на брак + if not can_sew: return redirect(url_for('sewing')) - # 3. Обновляем количество всех материалов, которые нужно списать + # 3. Обновляем количество материалов materials_list = data.get('materials', []) for mat_id, qty_to_deduct in materials_to_update.items(): updated = False @@ -687,78 +659,66 @@ def sewing(): if mat.get('id') == mat_id: current_qty_dec = to_decimal(mat.get('quantity', '0')) new_quantity_dec = current_qty_dec - qty_to_deduct - # Проверка на отрицательный остаток (на всякий случай) - if new_quantity_dec < 0: - logging.error(f"Ошибка расчета: отрицательный остаток для материала {mat_id}. Остаток: {new_quantity_dec}. Списание: {qty_to_deduct}") - new_quantity_dec = Decimal('0.00') # Не уходим в минус - - materials_list[i]['quantity'] = str(new_quantity_dec) # Сохраняем как строку + if new_quantity_dec < 0: new_quantity_dec = Decimal('0.00') + materials_list[i]['quantity'] = str(new_quantity_dec) materials_list[i]['timestamp_last_updated'] = current_time - logging.info(f"Списано материала '{mat['name']}' (ID: {mat_id}): {qty_to_deduct}. Остаток: {new_quantity_dec}") - updated = True - break + updated = True; break if not updated: - logging.error(f"Не удалось найти материал с ID {mat_id} для обновления остатка при пошиве.") - # Решить, что делать - прерывать или продолжать с ошибкой? flash(f"Критическая ошибка: Не удалось найти материал ID {mat_id} для списания.", "danger") return redirect(url_for('sewing')) - # 4. Создание задания на пошив + # 4. Расчет ЗП швеи + salary_sewer = to_decimal(config.get('salary_sewer_per_unit', '0.00')) + sewing_salary_cost = Decimal(sewn_quantity) * salary_sewer + + # 5. Создание задания на пошив new_sewing_task = { 'id': uuid.uuid4().hex, 'cutting_task_id': cutting_task_id, 'product_name': sewn_product_name, 'sewn_quantity': sewn_quantity, - 'fabric_id': cutting_task['fabric_id'], # Сохраняем для справки + 'fabric_id': cutting_task['fabric_id'], 'fabric_name': cutting_task['fabric_name'], - 'fittings_consumed': fittings_consumed_list, # Фактически использовано на пошив - 'defects_reported': [], # Заполним ниже, преобразовав Decimal в строки - 'status': 'pending_qc', # 'pending_qc', 'completed' (packed) + 'fittings_consumed': fittings_consumed_list, # Список использованной фурнитуры с кол-вом и стоимостью + 'defects_reported': [], # Будет ниже + 'status': 'pending_qc', 'timestamp_created': current_time, - 'timestamp_completed': None, # Заполнится при ОТК - 'qc_packed_quantity': 0, # Инициализация полей ОТК - 'qc_defective_quantity': 0 + 'timestamp_completed': None, + 'qc_packed_quantity': 0, + 'qc_defective_quantity': 0, + # Новые поля стоимости + 'fittings_cost': str(total_fittings_cost), # Общая стоимость фурнитуры на этот пошив + 'sewing_salary_cost': str(sewing_salary_cost), # ЗП швей на этот пошив + 'cutting_salary_cost': cutting_task.get('cutting_salary_cost', '0.00') # Копируем ЗП раскроя для удобства } - # Присваиваем ID задания на пошив и преобразуем Decimal в строку для JSON + # Добавляем ID задания в лог брака и преобразуем Decimal обратно в строку for defect in defect_log_list: defect['sewing_task_id'] = new_sewing_task['id'] - # Преобразуем Decimal обратно в строку для JSON, если это ткань if isinstance(defect['quantity'], Decimal): defect['quantity'] = str(defect['quantity']) - # Добавляем в список задания new_sewing_task['defects_reported'].append(defect) - - # 5. Обновление статуса задания на раскрой + # 6. Обновление статуса задания на раскрой task_updated = False for i, task in enumerate(data.get('cutting_tasks', [])): if task.get('id') == cutting_task_id: - task['status'] = 'completed' # Считаем раскрой завершенным + task['status'] = 'completed' task['timestamp_completed'] = current_time - task_updated = True - break - if not task_updated: - logging.error(f"Не удалось найти задание на раскрой ID {cutting_task_id} для обновления статуса.") - # Решить, что делать + task_updated = True; break + if not task_updated: logging.error(f"Не удалось обновить статус задания на раскрой ID {cutting_task_id}.") - # 6. Добавление задания на пошив и записей о браке в базу + # 7. Добавление данных в базу if 'sewing_tasks' not in data: data['sewing_tasks'] = [] data['sewing_tasks'].append(new_sewing_task) - - if defect_log_list: # Если был брак + if defect_log_list: if 'defect_log' not in data: data['defect_log'] = [] - # Добавляем уже обработанные записи из new_sewing_task['defects_reported'] data['defect_log'].extend(new_sewing_task['defects_reported']) - - # Обновляем основной список материалов data['materials'] = materials_list save_data(data) - flash(f"Успешно зарегистрирован пошив {sewn_quantity} ед. изделия '{sewn_product_name}'. Задание отправлено на ОТК.", "success") - if defect_log_list: - flash(f"Зарегистрирован брак: {len(defect_log_list)} позиций.", "warning") - + flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' зарегистрирован (Стоимость фурнитуры: {total_fittings_cost:.2f}, ЗП пошива: {sewing_salary_cost:.2f}). Отправлено на ОТК.", "success") + if defect_log_list: flash(f"Зарегистрирован брак: {len(defect_log_list)} позиций.", "warning") return redirect(url_for('sewing')) except Exception as e: @@ -766,152 +726,219 @@ def sewing(): flash(f"Произошла внутренняя ошибка при обработке пошива: {e}", "danger") # --- GET запрос --- - # Преобразуем данные для шаблона tasks_for_template = [] - all_materials_dict = {m['id']: m for m in data.get('materials', [])} # Словарь для быстрого поиска остатков - - for task in pending_cutting_tasks: - task_copy = task.copy() - # Преобразуем числа для отображения и data-атрибутов - task_copy['cut_items_quantity_int'] = int(task_copy.get('cut_items_quantity', 0)) - task_copy['fabric_used_decimal'] = to_decimal(task_copy.get('fabric_used', '0')) - # Получаем текущие остатки фурнитуры для отображения в деталях - if 'required_fittings' in task_copy: - for fitting in task_copy['required_fittings']: - mat_data = all_materials_dict.get(fitting['fitting_id']) - fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0') - fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт' - fitting['quantity_needed_int'] = int(fitting.get('quantity_needed',0)) # Преобразуем для JSON - tasks_for_template.append(task_copy) - - # Получаем все материалы для выбора брака (с преобразованными количествами) - all_materials_dec = [] - for m_id, m_data in all_materials_dict.items(): - m_copy = m_data.copy() - m_copy['quantity_decimal'] = to_decimal(m_copy.get('quantity', '0')) - all_materials_dec.append(m_copy) + all_materials_dict = {m['id']: m for m in data.get('materials', [])} + + for task_id in [t['id'] for t in pending_cutting_tasks]: # Получаем актуальные данные + task_data = find_item_by_id(task_id, 'cutting_tasks') + if task_data: + # Добавляем инфо о доступности фурнитуры + if 'required_fittings' in task_data: + for fitting in task_data['required_fittings']: + mat_data = all_materials_dict.get(fitting['fitting_id']) + fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0') + fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт' + # quantity_needed уже int из find_item_by_id + # Преобразуем Decimal в строки для JSON в data-атрибутах + task_data['material_cost_str'] = str(task_data.get('material_cost', '0.00')) + task_data['cutting_salary_cost_str'] = str(task_data.get('cutting_salary_cost', '0.00')) + task_data['fabric_used_str'] = str(task_data.get('fabric_used', '0.00')) + + tasks_for_template.append(task_data) + all_materials_dec = [] + for m_id in all_materials_dict: + mat_data = find_item_by_id(m_id, 'materials') + if mat_data: + mat_data['quantity_str'] = str(mat_data.get('quantity', '0.00')) + mat_data['price_str'] = str(mat_data.get('price_per_unit', '0.00')) + all_materials_dec.append(mat_data) page_title = "Пошив изделий" page_content = SEWING_CONTENT page_scripts = SEWING_SCRIPTS - html = BASE_TEMPLATE.replace('__TITLE__', page_title) html = html.replace('__CONTENT__', page_content) html = html.replace('__SCRIPTS__', page_scripts) + return render_template_string(html, cutting_tasks=tasks_for_template, all_materials=all_materials_dec) - return render_template_string(html, - cutting_tasks=tasks_for_template, - all_materials=all_materials_dec) # Передаем материалы с quantity_decimal # 4. Маршрут "ОТК и Упаковка" @app.route('/qc_packing', methods=['GET', 'POST']) def qc_packing(): - """Страница для отметки проверки качества и упаковки.""" data = load_data() - # Получаем задания на пошив, ожидающие ОТК pending_sewing_tasks = [t for t in data.get('sewing_tasks', []) if t.get('status') == 'pending_qc'] + config = data.get('config', initialize_data_structure()['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') # Брак на этапе ОТК + quantity_defective_str = request.form.get('quantity_defective', '0') defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() if not sewing_task_id or not quantity_packed_str: - flash("Выберите задание и укажите количество упакованных изделий.", "danger") + flash("Выберите задание и укажите количество упакованных.", "danger") return redirect(url_for('qc_packing')) - sewing_task = find_sewing_task_by_id(sewing_task_id) # Уже с int количествами + 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 = 0 - if quantity_packed_str: - quantity_packed = int(quantity_packed_str) - - quantity_defective = 0 - if quantity_defective_str: - quantity_defective = int(quantity_defective_str) - - if quantity_packed < 0 or quantity_defective < 0: - flash("Количество не может быть отрицательным.", "danger") - return redirect(url_for('qc_packing')) + quantity_packed = int(quantity_packed_str) if quantity_packed_str else 0 + quantity_defective = int(quantity_defective_str) if quantity_defective_str else 0 + if quantity_packed < 0 or quantity_defective < 0: raise ValueError total_processed = quantity_packed + quantity_defective sewn_quantity_task = sewing_task.get('sewn_quantity', 0) if total_processed == 0: - flash("Укажите количество упакованных или бракованных изделий (хотя бы одно должно быть больше нуля).", "warning") + flash("Укажите количество упакованных или бракованных (хотя бы одно > 0).", "warning") return redirect(url_for('qc_packing')) - - # Проверяем, не пытаются ли обработать больше, чем было сшито ИЗНАЧАЛЬНО if total_processed > sewn_quantity_task: - flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed}, что больше, чем было сшито ({sewn_quantity_task}).", "danger") + flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed}, что больше сшитых ({sewn_quantity_task}).", "danger") return redirect(url_for('qc_packing')) - except ValueError: - flash("Некорректное количество изделий.", "danger") + flash("Некорректное количество изделий (должно быть целым неотрицательным).", "danger") return redirect(url_for('qc_packing')) - current_time = datetime.now().isoformat() + current_time = get_current_time().isoformat() - # Создание записи о готовой продукции - if quantity_packed > 0: - packed_item = { - 'id': uuid.uuid4().hex, - 'sewing_task_id': sewing_task_id, - 'product_name': sewing_task['product_name'], - 'quantity': quantity_packed, - 'timestamp_packed': current_time - } - if 'qc_packing_items' not in data: data['qc_packing_items'] = [] - data['qc_packing_items'].append(packed_item) + # Расчет стоимости и цены для упакованных изделий + packed_material_cost_batch = Decimal('0.00') + packed_salary_cost_batch = Decimal('0.00') + packed_total_cost_batch = Decimal('0.00') + packed_margin_batch = Decimal('0.00') + packed_final_price_batch = Decimal('0.00') - # Регистрация брака на этапе ОТК (если есть) + if quantity_packed > 0: + # Получаем данные из заданий + cutting_task = find_item_by_id(sewing_task.get('cutting_task_id'), 'cutting_tasks') + if not cutting_task: + flash("Критическая ошибка: Не найдено связанное задание на раскрой! Расчет стоимости невозможен.", "danger") + # Можно или остановить, или продолжить с нулевыми затратами на раскрой + # return redirect(url_for('qc_packing')) + cutting_task = {'material_cost': Decimal('0.00'), 'cutting_salary_cost': Decimal('0.00'), 'cut_items_quantity': 1} # Заглушка + + # Получаем настройки + salary_packer = to_decimal(config.get('salary_packer_per_unit', '0.00')) + margin_per_item = to_decimal(config.get('margin_per_item', '0.00')) + + # Стоимости из заданий (уже Decimal) + fabric_cost_total_task = cutting_task.get('material_cost', Decimal('0.00')) + cutting_salary_total_task = cutting_task.get('cutting_salary_cost', Decimal('0.00')) + fittings_cost_total_task = sewing_task.get('fittings_cost', Decimal('0.00')) + sewing_salary_total_task = sewing_task.get('sewing_salary_cost', Decimal('0.00')) + + # Исходные количества для расчета "на единицу" + initial_cut_qty = cutting_task.get('cut_items_quantity', 1) # Защита от деления на 0 + initial_sewn_qty = sewing_task.get('sewn_quantity', 1) # Защита от деления на 0 + if initial_cut_qty == 0: initial_cut_qty = 1 + if initial_sewn_qty == 0: initial_sewn_qty = 1 + + # Расчет стоимости *на одно* изделие + fabric_cost_per_item = fabric_cost_total_task / Decimal(initial_cut_qty) + fittings_cost_per_item = fittings_cost_total_task / Decimal(initial_sewn_qty) + cutting_salary_per_item = cutting_salary_total_task / Decimal(initial_cut_qty) + sewing_salary_per_item = sewing_salary_total_task / Decimal(initial_sewn_qty) + packing_salary_per_item = salary_packer # ЗП упаковщика уже "на единицу" + + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + 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 + final_price_per_item = total_cost_per_item + margin_per_item + + # Расчет общей стоимости для *этой партии* упакованных + packed_material_cost_batch = material_cost_per_item * Decimal(quantity_packed) + packed_salary_cost_batch = salary_cost_per_item * Decimal(quantity_packed) + packed_total_cost_batch = total_cost_per_item * Decimal(quantity_packed) + packed_margin_batch = margin_per_item * Decimal(quantity_packed) + packed_final_price_batch = final_price_per_item * Decimal(quantity_packed) + + # Создание записи о готовой продукции + packed_item = { + 'id': uuid.uuid4().hex, + 'sewing_task_id': sewing_task_id, + 'product_name': sewing_task['product_name'], + 'quantity': quantity_packed, + 'timestamp_packed': current_time, + # Новые поля стоимости для этой партии + 'packed_material_cost': str(packed_material_cost_batch), + 'packed_salary_cost': str(packed_salary_cost_batch), + 'packed_total_cost': str(packed_total_cost_batch), + 'packed_margin': str(packed_margin_batch), + 'packed_final_price': str(packed_final_price_batch) + } + if 'qc_packing_items' not in data: data['qc_packing_items'] = [] + data['qc_packing_items'].append(packed_item) + + # Регистрация брака на этапе ОТК if quantity_defective > 0: + # Стоимость бракованного изделия (без ЗП упаковщика и маржи, но с остальными затратами) + # Используем расчеты per_item, сделанные выше, если quantity_packed > 0 + # Если quantity_packed == 0, нужно пересчитать per_item здесь + if quantity_packed == 0: + cutting_task = find_item_by_id(sewing_task.get('cutting_task_id'), 'cutting_tasks') + if not cutting_task: cutting_task = {'material_cost': Decimal('0.00'), 'cutting_salary_cost': Decimal('0.00'), 'cut_items_quantity': 1} + fabric_cost_total_task = cutting_task.get('material_cost', Decimal('0.00')) + cutting_salary_total_task = cutting_task.get('cutting_salary_cost', Decimal('0.00')) + fittings_cost_total_task = sewing_task.get('fittings_cost', Decimal('0.00')) + sewing_salary_total_task = sewing_task.get('sewing_salary_cost', Decimal('0.00')) + initial_cut_qty = cutting_task.get('cut_items_quantity', 1); initial_sewn_qty = sewing_task.get('sewn_quantity', 1) + if initial_cut_qty == 0: initial_cut_qty = 1 + if initial_sewn_qty == 0: initial_sewn_qty = 1 + fabric_cost_per_item = fabric_cost_total_task / Decimal(initial_cut_qty) + fittings_cost_per_item = fittings_cost_total_task / Decimal(initial_sewn_qty) + cutting_salary_per_item = cutting_salary_total_task / Decimal(initial_cut_qty) + sewing_salary_per_item = sewing_salary_total_task / Decimal(initial_sewn_qty) + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + salary_cost_per_item_defect = cutting_salary_per_item + sewing_salary_per_item # Без упаковщика + + cost_per_defective_item = material_cost_per_item + salary_cost_per_item_defect + total_defect_cost = cost_per_defective_item * Decimal(quantity_defective) + defect_log_entry = { 'log_id': uuid.uuid4().hex, - 'material_id': None, # Брак готового изделия, не материала + 'material_id': None, 'material_name': sewing_task['product_name'] + " (готовое изделие)", - 'quantity': quantity_defective, # int + 'quantity': quantity_defective, 'unit': 'шт', 'type': 'finished_product', 'stage': 'qc_packing', 'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке', + 'cost': str(total_defect_cost), # Стоимость брака 'sewing_task_id': sewing_task_id, 'timestamp': current_time } if 'defect_log' not in data: data['defect_log'] = [] data['defect_log'].append(defect_log_entry) - logging.info(f"Зарегистрирован брак {quantity_defective} ед. готового изделия '{sewing_task['product_name']}' на этапе ОТК.") + logging.info(f"Зарегистрирован брак {quantity_defective} ед. готового изделия '{sewing_task['product_name']}' (Стоимость брака: {total_defect_cost:.2f})") - - # Обновление статуса и данных в задании на пошив + # Обновление статуса задания на пошив task_updated = False for i, task in enumerate(data.get('sewing_tasks', [])): if task.get('id') == sewing_task_id: - # Обновляем информацию о результате ОТК в само задание - # Убедимся, что читаем существующие значения как int packed_before = int(task.get('qc_packed_quantity', 0)) defective_before = int(task.get('qc_defective_quantity', 0)) - task['qc_packed_quantity'] = packed_before + quantity_packed task['qc_defective_quantity'] = defective_before + quantity_defective - task['status'] = 'completed' # Считаем завершенным после обработки - task['timestamp_completed'] = current_time # Время последнего ОТК - task_updated = True - break - - if not task_updated: - logging.error(f"Не удалось найти задание на пошив ID {sewing_task_id} для обновления данных ОТК.") - # Решить, что делать + # Меняем статус на completed только если обработаны ВСЕ сшитые изделия + if task['qc_packed_quantity'] + task['qc_defective_quantity'] >= int(task.get('sewn_quantity', 0)): + task['status'] = 'completed' + task['timestamp_completed'] = current_time # Время полного завершения ОТК + else: + # Если обработана только часть, статус остается pending_qc + task['status'] = 'pending_qc' + # Можно добавить поле last_qc_timestamp, если нужно отслеживать частичные ОТК + task_updated = True; break + + if not task_updated: logging.error(f"Не удалось обновить задание на пошив ID {sewing_task_id} после ОТК.") save_data(data) - flash(f"ОТК и упаковка для задания '{sewing_task['product_name']}' зарегистрированы: упаковано {quantity_packed}, брак {quantity_defective}.", "success") + flash(f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed}, брак {quantity_defective}. " + f"Стоимость уп.: {packed_total_cost_batch:.2f}, Цена уп.: {packed_final_price_batch:.2f}", "success") return redirect(url_for('qc_packing')) except Exception as e: @@ -919,111 +946,70 @@ def qc_packing(): flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger") # --- GET запрос --- + tasks_for_template = [] + for task_id in [t['id'] for t in pending_sewing_tasks]: + task_data = find_item_by_id(task_id, 'sewing_tasks') + if task_data: + # Рассчитываем, сколько еще осталось обработать + remaining_qty = task_data['sewn_quantity'] - (task_data['qc_packed_quantity'] + task_data['qc_defective_quantity']) + task_data['remaining_quantity'] = remaining_qty + tasks_for_template.append(task_data) + + page_title = "ОТК и Упаковка" page_content = QC_PACKING_CONTENT page_scripts = QC_PACKING_SCRIPTS - html = BASE_TEMPLATE.replace('__TITLE__', page_title) html = html.replace('__CONTENT__', page_content) html = html.replace('__SCRIPTS__', page_scripts) - - # Передаем задачи, ожидающие ОТК, в шаблон - return render_template_string(html, sewing_tasks=pending_sewing_tasks) + return render_template_string(html, sewing_tasks=tasks_for_template) # 5. Маршрут "Админ-панель" @app.route('/admin') def admin_panel(): - """Отображение сводной информации по всем этапам.""" data = load_data() + config = data.get('config', initialize_data_structure()['config']) - # Подготовка данных для отображения - materials = data.get('materials', []) - cutting_tasks = data.get('cutting_tasks', []) - sewing_tasks = data.get('sewing_tasks', []) - packed_items = data.get('qc_packing_items', []) - defect_log = data.get('defect_log', []) - categories = data.get('categories', []) - - # Преобразуем количества в Decimal/int для корректного отображения и расчетов - materials_view = [] - for m in materials: - m_copy = m.copy() - m_copy['quantity_dec'] = to_decimal(m_copy.get('quantity', '0')) - m_copy['items_per_unit_int'] = int(m_copy.get('items_per_unit', 0)) - materials_view.append(m_copy) - - cutting_tasks_view = [] - for t in cutting_tasks: - t_copy = t.copy() - t_copy['fabric_used_dec'] = to_decimal(t_copy.get('fabric_used', '0')) - t_copy['cut_items_quantity_int'] = int(t_copy.get('cut_items_quantity', 0)) - # Преобразуем кол-во в фиттингах - if 'required_fittings' in t_copy: - for fit in t_copy['required_fittings']: - fit['quantity_needed_int'] = int(fit.get('quantity_needed', 0)) - cutting_tasks_view.append(t_copy) - - sewing_tasks_view = [] - for t in sewing_tasks: - # Используем find_sewing_task_by_id, чтобы получить уже преобразованные числа - task_data = find_sewing_task_by_id(t['id']) - if task_data: # Убедимся, что задача найдена - # Преобразуем Decimal в строку для дефектов ткани перед передачей в шаблон - if 'defects_reported' in task_data: - for defect in task_data['defects_reported']: - if isinstance(defect.get('quantity'), Decimal): - defect['quantity'] = str(defect['quantity']) - sewing_tasks_view.append(task_data) - else: - logging.warning(f"Задача на пошив с ID {t['id']} не найдена при подготовке для админки.") - # Можно добавить копию исходной задачи t, но числа могут быть строками - sewing_tasks_view.append(t.copy()) - - - packed_items_view = [] - total_packed_count = 0 - for item in packed_items: - item_copy = item.copy() - item_quantity = int(item_copy.get('quantity', 0)) - item_copy['quantity_int'] = item_quantity - total_packed_count += item_quantity - packed_items_view.append(item_copy) - + # Подготовка данных для отображения (используем find_item_by_id) + materials_view = [find_item_by_id(m['id'], 'materials') for m in data.get('materials', []) if find_item_by_id(m['id'], 'materials')] + cutting_tasks_view = [find_item_by_id(t['id'], 'cutting_tasks') for t in data.get('cutting_tasks', []) if find_item_by_id(t['id'], 'cutting_tasks')] + sewing_tasks_view = [find_item_by_id(t['id'], 'sewing_tasks') for t in data.get('sewing_tasks', []) if find_item_by_id(t['id'], 'sewing_tasks')] + packed_items_view = [find_item_by_id(i['id'], 'qc_packing_items') for i in data.get('qc_packing_items', []) if find_item_by_id(i['id'], 'qc_packing_items')] defect_log_view = [] - # total_defect_items = 0 # Будем считать штуки, метры отдельно - total_defect_fabric_m = Decimal('0.00') - total_defect_fittings_pcs = 0 - total_defect_finished_pcs = 0 - - for defect in defect_log: + for defect in data.get('defect_log', []): + # Преобразуем стоимость брака defect_copy = defect.copy() + defect_copy['cost_dec'] = to_decimal(defect.get('cost', '0.00')) + # Форматируем количество для отображения qty = defect_copy.get('quantity', 0) - unit = defect_copy.get('unit', '') - dtype = defect_copy.get('type', '') - - if dtype == 'fabric': - qty_dec = to_decimal(str(qty)) # Преобразуем, если строка - defect_copy['quantity_view'] = f"{qty_dec}".replace('.', ',') # Формат для вывода - total_defect_fabric_m += qty_dec - elif dtype == 'fittings': - qty_int = int(Decimal(str(qty))) # Преобра��уем безопасно - defect_copy['quantity_view'] = f"{qty_int}" - total_defect_fittings_pcs += qty_int - elif dtype == 'finished_product': - qty_int = int(Decimal(str(qty))) - defect_copy['quantity_view'] = f"{qty_int}" - total_defect_finished_pcs += qty_int + if defect_copy.get('type') == 'fabric': + defect_copy['quantity_view'] = f"{to_decimal(str(qty)):.2f}".replace('.', ',') else: - defect_copy['quantity_view'] = str(qty) # Как есть - + defect_copy['quantity_view'] = f"{int(to_decimal(str(qty)))}" defect_log_view.append(defect_copy) + expenses_view = [find_item_by_id(e['id'], 'expenses') for e in data.get('expenses', []) if find_item_by_id(e['id'], 'expenses')] + categories = data.get('categories', []) + + # Подсчет итогов для сводки + materials_count = len(materials_view) + pending_cutting_count = len([t for t in cutting_tasks_view if t.get('status') == 'pending']) + pending_qc_count = len([t for t in sewing_tasks_view if t.get('status') == 'pending_qc']) + total_packed_count = sum(item.get('quantity', 0) for item in packed_items_view) + + total_defect_fabric_m = sum(d['quantity'] for d in defect_log_view if d.get('type') == 'fabric') + total_defect_fittings_pcs = sum(d['quantity'] for d in defect_log_view if d.get('type') == 'fittings') + total_defect_finished_pcs = sum(d['quantity'] for d in defect_log_view if d.get('type') == 'finished_product') + total_defect_cost = sum(d.get('cost_dec', Decimal('0.00')) for d in defect_log_view) + + # Преобразуем конфиг в Decimal для передачи в шаблон + config_dec = {k: to_decimal(v) for k, v in config.items()} + page_title = "Админ-панель" page_content = ADMIN_CONTENT - page_scripts = ADMIN_SCRIPTS # Если есть специфичные скрипты для админки - + page_scripts = ADMIN_SCRIPTS html = BASE_TEMPLATE.replace('__TITLE__', page_title) html = html.replace('__CONTENT__', page_content) html = html.replace('__SCRIPTS__', page_scripts) @@ -1036,34 +1022,88 @@ def admin_panel(): sewing_tasks=sewing_tasks_view, packed_items=packed_items_view, defect_log=defect_log_view, + expenses=expenses_view, categories=categories, - # Передаем итоги для сводки - materials_count=len(materials_view), - pending_cutting_count=len([t for t in cutting_tasks_view if t.get('status') == 'pending']), - pending_qc_count=len([t for t in sewing_tasks_view if t.get('status') == 'pending_qc']), + config=config_dec, # Передаем с Decimal значениями + # Сводка + materials_count=materials_count, + pending_cutting_count=pending_cutting_count, + pending_qc_count=pending_qc_count, total_packed_count=total_packed_count, - total_defect_fabric_m=str(total_defect_fabric_m).replace('.', ','), # Строка для вывода + total_defect_fabric_m=f"{total_defect_fabric_m:.2f}".replace('.',','), total_defect_fittings_pcs=total_defect_fittings_pcs, - total_defect_finished_pcs=total_defect_finished_pcs + total_defect_finished_pcs=total_defect_finished_pcs, + total_defect_cost=f"{total_defect_cost:.2f}" # Общая стоимость брака ) -# Маршруты для управления категориями в админке +# Маршрут для обновления настроек ЗП и Маржи +@app.route('/admin/config/update', methods=['POST']) +def update_config(): + data = load_data() + config = data.get('config', {}) # Получаем или создаем пустой + + try: + config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter'))) + config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer'))) + config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer'))) + config['margin_per_item'] = str(to_decimal(request.form.get('margin'))) + + data['config'] = config + save_data(data) + flash("Настройки зарплат и маржи успешно обновлены.", "success") + 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() + if 'expenses' not in data: data['expenses'] = [] + + description = request.form.get('expense_description', '').strip() + amount_str = request.form.get('expense_amount') + + if not description or not amount_str: + flash("Заполните описание и сумму расхода.", "warning") + return redirect(url_for('admin_panel')) + + amount = to_decimal(amount_str) + if amount <= 0: + flash("Сумма расхода должна быть положительной.", "warning") + return redirect(url_for('admin_panel')) + + 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}' на сумму {amount:.2f} успешно добавлен.", "success") + + return redirect(url_for('admin_panel')) + + +# Маршруты управления категориями (без изменений) @app.route('/admin/category/add', methods=['POST']) def add_category(): data = load_data() categories = data.get('categories', []) new_category = request.form.get('new_category_name', '').strip() - if new_category and new_category.lower() not in [c.lower() for c in categories]: # Проверка без учета регистра + if new_category and new_category.lower() not in [c.lower() for c in categories]: categories.append(new_category) - data['categories'] = sorted(list(set(categories)), key=str.lower) # Сортировка без учета регистра + data['categories'] = sorted(list(set(categories)), key=str.lower) save_data(data) flash(f"Категория '{new_category}' успешно добавлена.", "success") elif not new_category: flash("Название категории не может быть пустым.", "warning") else: flash(f"Категория '{new_category}' уже существует.", "warning") - return redirect(url_for('admin_panel')) @app.route('/admin/category/delete', methods=['POST']) @@ -1076,54 +1116,41 @@ def delete_category(): flash("Нельзя удалить базовую категорию 'Без категории'.", "danger") return redirect(url_for('admin_panel')) - - # Ищем категорию без учета регистра для удаления, но сохраняем оригинальное название для сообщений original_category_name = None category_found = False for cat in categories: if cat.lower() == category_to_delete.lower(): - original_category_name = cat - category_found = True - break + original_category_name = cat; category_found = True; break if category_found and original_category_name: categories.remove(original_category_name) data['categories'] = sorted(categories, key=str.lower) - - # Обновляем категорию у материалов - materials = data.get('materials', []) updated_count = 0 - for mat in materials: - # Сравниваем без учета регистра + for mat in data.get('materials', []): if mat.get('category', 'Без категории').lower() == original_category_name.lower(): mat['category'] = 'Без категории' - mat['timestamp_last_updated'] = datetime.now().isoformat() + mat['timestamp_last_updated'] = get_current_time().isoformat() updated_count += 1 - save_data(data) flash(f"Категория '{original_category_name}' удалена.", "success") - if updated_count > 0: - flash(f"{updated_count} материалов перенесены в категорию 'Без категории'.", "info") + if updated_count > 0: flash(f"{updated_count} материалов перенесены в 'Без категории'.", "info") elif not category_to_delete: flash("Не выбрана категория для удаления.", "warning") else: flash(f"Категория '{category_to_delete}' не найдена.", "warning") - return redirect(url_for('admin_panel')) -# Маршруты для Hugging Face +# Маршруты Hugging Face (без изменений) @app.route('/backup', methods=['POST']) def backup_hf(): - """Принудительно создает резервную копию на HF.""" try: logging.info("Запуск ручного резервного копирования на Hugging Face...") - # Убедимся, что локальный файл существует перед загрузкой if os.path.exists(DATA_FILE): - upload_db_to_hf() # Просто загружаем текущий локальный файл + upload_db_to_hf() flash("Резервная копия успешно загружена на Hugging Face.", "success") else: - flash("Локальный файл данных не найден. Нечего загружать.", "warning") + flash("Локальный файл данных не найден.", "warning") except Exception as e: logging.error(f"Ошибка при ручном резервном копировании: {e}") flash(f"Ошибка при создании резервной копии: {e}", "danger") @@ -1131,38 +1158,175 @@ def backup_hf(): @app.route('/download', methods=['GET']) def download_hf(): - """Принудительно скачивает базу данных с HF (перезаписывает локальную).""" - # !!! ОСТОРОЖНО: Эта операция перезапишет локальные несинхронизированные изменения !!! try: logging.info("Запуск ручного скачивания базы данных с Hugging Face...") - # load_data() уже скачивает, но можно вызвать явно hf_hub_download для надежности 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 # Принудительно скачать + repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", + token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, + force_download=True ) flash(f"База данных успешно скачана с Hugging Face. Локальный файл {DATA_FILE} обновлен.", "success") except RepositoryNotFoundError: - flash(f"Репозиторий {REPO_ID} не найден на Hugging Face.", "danger") + flash(f"Репозиторий {REPO_ID} не найден.", "danger") except HfHubHTTPError as e: - if e.response.status_code == 404: - flash(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}.", "danger") - else: - flash(f"Ошибка HTTP при скачивании из Hugging Face: {e}", "danger") - logging.error(f"Ошибка HTTP при ручном скачивании базы данных: {e}") + if e.response.status_code == 404: flash(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}.", "danger") + else: flash(f"Ошибка HTTP при скачивании: {e}", "danger"); logging.error(f"Ошибка HTTP при скачивании: {e}") except Exception as e: - logging.error(f"Ошибка при ручном скачивании базы данных: {e}") - flash(f"Ошибка при скачивании базы данных: {e}", "danger") + logging.error(f"Ошибка при ручном скачивании: {e}") + flash(f"Ошибка при скачивании: {e}", "danger") return redirect(url_for('admin_panel')) +# 6. Маршрут "Отчеты" +@app.route('/reports', methods=['GET']) +def reports(): + data = load_data() + now = get_current_time() + + # Получаем параметры фильтра + filter_type = request.args.get('filter', 'month') # month, week, day, year, custom + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + # Определяем даты начала и конца периода + end_date = None + start_date = None + + try: + if filter_type == 'custom' and start_date_str and end_date_str: + start_date = BISHKEK_TZ.localize(datetime.strptime(start_date_str, '%Y-%m-%d')).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = BISHKEK_TZ.localize(datetime.strptime(end_date_str, '%Y-%m-%d')).replace(hour=23, minute=59, second=59, microsecond=999999) + elif filter_type == 'day': + day_str = request.args.get('date', now.strftime('%Y-%m-%d')) + start_date = BISHKEK_TZ.localize(datetime.strptime(day_str, '%Y-%m-%d')).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = start_date.replace(hour=23, minute=59, second=59, microsecond=999999) + elif filter_type == 'week': + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + start_date = today - timedelta(days=today.weekday()) # Понедельник текущей недели + end_date = start_date + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) # Воскресенье + elif filter_type == 'year': + year_str = request.args.get('year', str(now.year)) + start_date = BISHKEK_TZ.localize(datetime(int(year_str), 1, 1, 0, 0, 0)) + end_date = BISHKEK_TZ.localize(datetime(int(year_str), 12, 31, 23, 59, 59, 999999)) + else: # month (default) + month_str = request.args.get('month', now.strftime('%Y-%m')) + year, month = map(int, month_str.split('-')) + start_date = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0)) + next_month = start_date.replace(day=28) + timedelta(days=4) # Переход к следующему месяцу + end_of_month = next_month - timedelta(days=next_month.day) + end_date = end_of_month.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Убедимся что даты корректны + if not start_date or not end_date or start_date > end_date: + raise ValueError("Некорректный диапазон дат") + + except ValueError as e: + flash(f"Ошибка в датах фильтра: {e}. Показывается отчет за текущий месяц.", "warning") + filter_type = 'month' + start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + next_month = start_date.replace(day=28) + timedelta(days=4) + end_of_month = next_month - timedelta(days=next_month.day) + end_date = end_of_month.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Фильтруем данные + filtered_packed_items = [] + for item_id in [item['id'] for item in data.get('qc_packing_items', [])]: + item_data = find_item_by_id(item_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 <= packed_time <= end_date: + filtered_packed_items.append(item_data) + + filtered_defects = [] + for defect in data.get('defect_log', []): + defect_time = parse_iso_datetime(defect.get('timestamp')) + if defect_time and start_date <= defect_time <= end_date: + defect_copy = defect.copy() + defect_copy['cost_dec'] = to_decimal(defect.get('cost', '0.00')) + # Форматируем количество для отображения + qty = defect_copy.get('quantity', 0) + if defect_copy.get('type') == 'fabric': + defect_copy['quantity_view'] = f"{to_decimal(str(qty)):.2f}".replace('.', ',') + else: + defect_copy['quantity_view'] = f"{int(to_decimal(str(qty)))}" + filtered_defects.append(defect_copy) + + + filtered_expenses = [] + for expense_id in [exp['id'] for exp in data.get('expenses', [])]: + expense_data = find_item_by_id(expense_id, 'expenses') + if not expense_data: continue + expense_time = parse_iso_datetime(expense_data.get('timestamp')) + if expense_time and start_date <= expense_time <= end_date: + filtered_expenses.append(expense_data) + + # Считаем итоги для отчета + total_packed_qty = sum(item.get('quantity', 0) for item in filtered_packed_items) + total_revenue = sum(item.get('packed_final_price', Decimal('0.00')) for item in filtered_packed_items) + total_material_cost = sum(item.get('packed_material_cost', Decimal('0.00')) for item in filtered_packed_items) + total_salary_cost = sum(item.get('packed_salary_cost', Decimal('0.00')) for item in filtered_packed_items) + total_margin = sum(item.get('packed_margin', Decimal('0.00')) for item in filtered_packed_items) + total_cost_packed = total_material_cost + total_salary_cost # Себестоимость упакованных + + total_defect_cost = sum(d.get('cost_dec', Decimal('0.00')) for d in filtered_defects) + total_expenses = sum(exp.get('amount', Decimal('0.00')) for exp in filtered_expenses) + + total_overall_cost = total_cost_packed + total_defect_cost + total_expenses + total_profit = total_revenue - total_overall_cost + + # Группировка по продуктам + production_summary = {} + for item in filtered_packed_items: + name = item.get('product_name', 'Неизвестный продукт') + qty = item.get('quantity', 0) + revenue = item.get('packed_final_price', Decimal('0.00')) + cost = item.get('packed_total_cost', Decimal('0.00')) # Себестоимость = материал + зп + маржа (или packed_total_cost?) packed_total_cost = материал + зп + profit = revenue - cost # Прибыль = Цена продажи - (материал + зп) + + if name not in production_summary: + production_summary[name] = {'quantity': 0, 'revenue': Decimal('0.00'), 'cost': Decimal('0.00'), 'profit': Decimal('0.00')} + production_summary[name]['quantity'] += qty + production_summary[name]['revenue'] += revenue + production_summary[name]['cost'] += item.get('packed_total_cost', Decimal('0.00')) # Используем packed_total_cost = материал + зп + production_summary[name]['profit'] += profit + + # Подготовка данных для шаблона + report_data = { + 'total_packed_qty': total_packed_qty, + 'total_revenue': total_revenue, + 'total_material_cost': total_material_cost, + 'total_salary_cost': total_salary_cost, + 'total_cost_packed': total_cost_packed, # Себестоимость упакованных (материал+зп) + 'total_defect_cost': total_defect_cost, + 'total_expenses': total_expenses, + 'total_overall_cost': total_overall_cost, # Общие затраты (упак + брак + доп) + 'total_profit': total_profit, + 'production_summary': production_summary, + 'filtered_packed_items': filtered_packed_items, + 'filtered_defects': filtered_defects, + 'filtered_expenses': filtered_expenses, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.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 # Передаем аргументы обратно в форму + } + + page_title = "Отчеты" + page_content = REPORTS_CONTENT + page_scripts = REPORTS_SCRIPTS + html = BASE_TEMPLATE.replace('__TITLE__', page_title) + html = html.replace('__CONTENT__', page_content) + html = html.replace('__SCRIPTS__', page_scripts) + return render_template_string(html, report=report_data) + + # --- HTML Шаблоны (как строки Python) --- -# Базовый шаблон с уникальными маркерами вместо .format() плейсхолдеров +# Базовый шаблон (добавлен пункт Отчеты в навигацию) BASE_TEMPLATE = """ @@ -1172,168 +1336,53 @@ BASE_TEMPLATE = """