diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -34,7 +34,7 @@ REPO_ID = "Kgshop/cech" # !!! Обновленный REPO_ID !!! BISHKEK_TZ = pytz.timezone('Asia/Bishkek') # --- Настройка логирования --- -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # Установите DEBUG для подробного лога # --- Блокировки для безопасной работы с файлами --- data_lock = threading.Lock() @@ -84,14 +84,17 @@ def load_data(): default_data = initialize_data_structure() for key in default_data.keys(): if key not in data: + logging.warning(f"В {DATA_FILE} отсутствует ключ '{key}'. Инициализация значением по умолчанию.") data[key] = default_data[key] # Дополнительно проверяем config if 'config' not in data or not isinstance(data['config'], dict): + logging.warning(f"В {DATA_FILE} отсутствует или некорректен ключ 'config'. Инициализация значением по умолчанию.") data['config'] = default_data['config'] else: # Проверяем наличие ключей внутри config 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 return data except FileNotFoundError: @@ -99,6 +102,13 @@ def load_data(): 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}") @@ -142,18 +152,72 @@ def load_client_data(): if not isinstance(clients, list): logging.warning(f"{CLIENT_DATA_FILE} не является списком, инициализация пустым списком.") return [] - return clients + # Проверка структуры каждого клиента (добавлено) + valid_clients = [] + for client in clients: + if isinstance(client, dict) and 'id' in client and 'name' in client: + # Проверка и исправление history, если необходимо + if 'history' not in client or not isinstance(client.get('history'), list): + logging.warning(f"Обнаружен некорректный формат 'history' для клиента {client.get('id')} при загрузке. Инициализировано пустым списком.") + client['history'] = [] + else: + # Дополнительная проверка элементов внутри history + valid_history = [] + for record in client['history']: + if isinstance(record, dict) and 'timestamp' in record: + # Проверка и исправление 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'] = [] + valid_history.append(record) + else: + logging.warning(f"Обнаружена некорректная запись в истории клиента {client.get('id')}. Пропущена: {record}") + client['history'] = valid_history + valid_clients.append(client) + else: + logging.warning(f"Обнаружена некорректная запись клиента в {CLIENT_DATA_FILE}. Пропущена: {client}") + return valid_clients except FileNotFoundError: logging.warning(f"Локальный файл {CLIENT_DATA_FILE} не найден. Инициализация пустым списком."); return [] - except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в файле {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}"); return [] def save_client_data(clients): """Сохраняет данные клиентов в JSON файл.""" with 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) + 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: @@ -187,6 +251,7 @@ def upload_db_to_hf(filepath=DATA_FILE): 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}", @@ -200,12 +265,25 @@ def periodic_backup(): """Периодически вызывает upload_db_to_hf для обоих файлов.""" logging.info("Запуск потока периодического резервного копирования.") while True: - time.sleep(1800) # Каждые 30 минут + backup_interval = 1800 # 30 минут + logging.debug(f"Периодический бэкап спит {backup_interval} секунд...") + time.sleep(backup_interval) logging.info("Запуск планового резервного копирования...") - with data_lock: - if os.path.exists(DATA_FILE): upload_db_to_hf(DATA_FILE) - with client_data_lock: - if os.path.exists(CLIENT_DATA_FILE): upload_db_to_hf(CLIENT_DATA_FILE) + 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): @@ -222,23 +300,37 @@ 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) - # Если временная зона отсутствует, считаем UTC и конвертируем в Бишкек + 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) - # Если временная зона уже есть, просто конвертируем в Бишкек - return dt.astimezone(BISHKEK_TZ) - except (ValueError, TypeError) as e: logging.warning(f"Не удалось разобрать дату: {timestamp_str}. Ошибка: {e}"); return None + 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) if item.get('id') == item_id or item.get('log_id') == item_id: item_copy = item.copy() # Возвращаем копию, чтобы не изменять исходные данные @@ -247,80 +339,91 @@ def find_item_by_id(item_id, item_list_name): 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'] - elif item_list_name == 'sewing_tasks': - decimal_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost'] - int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] - # Обработка вложенных структур - if 'fittings_consumed' in item_copy and isinstance(item_copy['fittings_consumed'], list): - for f in item_copy['fittings_consumed']: - 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']: - 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': - # log_id используется вместо id - decimal_fields = ['cost'] # Основное поле cost - qty_str = item_copy.get('quantity', '0') - defect_type = item_copy.get('type') - # Добавим поле _dec для удобства расчетов - item_copy['cost_dec'] = to_decimal(item_copy.get('cost', '0.00')) - # Добавим поля для отображения и сырое значение quantity - 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 # Оставляем как есть - - # Применяем преобразования - for field in decimal_fields: - item_copy[field] = to_decimal(item_copy.get(field)) - for field in int_fields: - # Используем to_decimal перед int для обработки строк типа "10.0" - item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) + try: # Обернем преобразования в try-except для большей устойчивости + if item_list_name == 'materials': + decimal_fields = ['quantity', 'price_per_unit'] + int_fields = ['items_per_unit'] + elif item_list_name == 'cutting_tasks': + decimal_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost'] + int_fields = ['cut_items_quantity'] + elif item_list_name == 'sewing_tasks': + decimal_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost'] + int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] + # Обработка вложенных структур + if 'fittings_consumed' in item_copy and isinstance(item_copy['fittings_consumed'], list): + for f in item_copy['fittings_consumed']: + if isinstance(f, dict): + f['quantity_used'] = int(to_decimal(f.get('quantity_used', '0'))) + f['cost'] = to_decimal(f.get('cost', '0.00')) + if 'defects_reported' in item_copy and isinstance(item_copy['defects_reported'], list): + for d in item_copy['defects_reported']: + if isinstance(d, dict): + qty_str = d.get('quantity', '0') + defect_type = d.get('type') + d['cost'] = to_decimal(d.get('cost', '0.00')) + # Преобразование quantity в зависимости от типа брака + if defect_type == 'fabric': + d['quantity'] = to_decimal(qty_str) # Оставляем Decimal для ткани + elif defect_type in ['fittings', 'finished_product']: + try: d['quantity'] = int(to_decimal(qty_str)) # Преобразуем в int + except (InvalidOperation, ValueError): d['quantity'] = 0 + else: d['quantity'] = 0 # Неизвестный тип + elif item_list_name == 'qc_packing_items': + decimal_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'] + int_fields = ['quantity'] + elif item_list_name == 'expenses': + decimal_fields = ['amount'] + elif item_list_name == 'defect_log': + # log_id используется вместо id + decimal_fields = ['cost'] # Основное поле cost + qty_str = item_copy.get('quantity', '0') + defect_type = item_copy.get('type') + # Добавим поле _dec для удобства расчетов + item_copy['cost_dec'] = to_decimal(item_copy.get('cost', '0.00')) + # Добавим поля для отображения и сырое значение quantity + 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 # Оставляем как есть + + # Применяем преобразования + for field in decimal_fields: + item_copy[field] = to_decimal(item_copy.get(field)) + for field in int_fields: + # Используем to_decimal перед int для обработки строк типа "10.0" + item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) + + except Exception as conversion_error: + logging.error(f"Ошибка преобразования типов для {item_list_name} ID {item_id}: {conversion_error}", exc_info=True) + # Можно вернуть item_copy без преобразований или None, в зависимости от требований + 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 = 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() # Преобразуем таймстемпы истории для удобства - if 'history' in client_copy and isinstance(client_copy['history'], list): + # 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 @@ -456,7 +559,8 @@ def procurement(): existing_material_index = -1 for idx, mat in enumerate(current_materials): # Сравниваем lowercase для имени и учитываем тип и категорию - if mat.get('name','').lower() == name.lower() and \ + 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 @@ -501,9 +605,8 @@ def procurement(): if valid_items_processed > 0 : if materials_to_add: data['materials'].extend(materials_to_add) # Добавляем новые - else: - # Если добавлялись только обновления, перезаписываем измененный current_materials - data['materials'] = current_materials + # data['materials'] уже содержит обновленные элементы, если были только обновления + # Обновляем и сортируем список категорий data['categories'] = sorted(list(set(categories)), key=str.lower) save_data(data) @@ -523,15 +626,17 @@ def procurement(): # GET запрос: отображаем страницу # Добавляем форматированные строки для отображения в шаблоне materials_display = [] - for m_id in [m.get('id') for m in data.get('materials', [])]: - m_data = find_item_by_id(m_id, 'materials') - if m_data: - if m_data['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) + 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) html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) return render_template_string(html, categories=categories, materials_display=materials_display) @@ -541,8 +646,13 @@ def procurement(): 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] + 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': @@ -558,7 +668,7 @@ def cutting(): # Ищем выбранную ткань в данных fabric_material = find_item_by_id(fabric_id, 'materials') - if not fabric_material: + if not fabric_material: # find_item_by_id вернет None, если не найдено или ошибка flash("Выбранная ткань не найдена в базе данных.", "danger") return redirect(url_for('cutting')) @@ -578,7 +688,8 @@ def cutting(): return redirect(url_for('cutting')) # Проверка наличия достаточного количества ткани - available_quantity = fabric_material.get('quantity', Decimal('0.00')) # find_item_by_id уже вернул Decimal + # 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']}, " @@ -586,6 +697,7 @@ def cutting(): 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 @@ -599,7 +711,7 @@ def cutting(): 'fabric_id': fabric_id, 'fabric_name': fabric_material['name'], # Сохраняем имя для удобства 'fabric_unit': fabric_material['unit'], # Сохраняем единицу измерения - 'cut_items_quantity': cut_items_quantity, + 'cut_items_quantity': cut_items_quantity, # int 'fabric_used': str(fabric_used), # Сохраняем как строку 'status': 'pending', # Начальный статус - ожидает пошива 'timestamp_created': creation_time, @@ -613,7 +725,7 @@ def cutting(): material_updated = False current_materials = data.get('materials', []) for i, mat in enumerate(current_materials): - if mat.get('id') == fabric_id: + 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 @@ -628,7 +740,7 @@ def cutting(): # Добавление задания в список и сохранение данных if 'cutting_tasks' not in data: data['cutting_tasks'] = [] data['cutting_tasks'].append(cutting_task) - data['materials'] = current_materials # Сохраняем обновленный список материалов + # data['materials'] уже содержит обновленный список материалов save_data(data) flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success") @@ -644,10 +756,11 @@ def cutting(): # Преобразуем данные о ткани для отображения fabrics_display = [] for f in fabrics: - 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) + 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) @@ -657,12 +770,20 @@ def cutting(): def sewing(): data = load_data() # Находим задания раскроя, ожидающие пошива - pending_cutting_tasks = [t for t in data.get('cutting_tasks', []) if t.get('status') == 'pending'] + 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 = [m for m in data.get('materials', []) - if m.get('type') == 'fittings' and to_decimal(m.get('quantity', '0')) > 0] + available_fittings = [] + for m in data.get('materials', []): + if isinstance(m, dict) and m.get('type') == 'fittings': + if to_decimal(m.get('quantity', '0')) > 0: + available_fittings.append(m) + # Все материалы (для выбора брака) - all_materials = data.get('materials', []) + all_materials = [m for m in data.get('materials', []) if isinstance(m, dict)] config = data.get('config', {}) if request.method == 'POST': @@ -693,7 +814,8 @@ def sewing(): # Валидация количества сшитых try: sewn_quantity = int(to_decimal(sewn_quantity_str).to_integral_value()) - cut_quantity = cutting_task.get('cut_items_quantity', 0) # find_item_by_id вернул int + # 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") @@ -729,21 +851,21 @@ def sewing(): is_valid = False; break # Проверка доступности с учетом уже запланированного списания - available_qty = fitting_material.get('quantity', Decimal('0')) # find_item_by_id вернул Decimal, преобразуем в int - available_qty_int = int(available_qty) - planned_deduction = materials_to_update.get(fitting_id, Decimal('0')) - if available_qty_int < planned_deduction + quantity_used: + # find_item_by_id вернул quantity как Decimal, преобразуем в int для фурнитуры + available_qty_int = int(fitting_material.get('quantity', Decimal('0'))) + planned_deduction_int = int(materials_to_update.get(fitting_id, Decimal('0'))) # Тоже в int + if available_qty_int < planned_deduction_int + quantity_used: flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. " f"В наличии: {format_integer_py(available_qty_int)}, " - f"уже запланировано списать: {format_integer_py(planned_deduction)}, " + f"уже запланировано списать: {format_integer_py(planned_deduction_int)}, " f"требуется еще: {format_integer_py(quantity_used)}.", "danger") is_valid = False; break - # Добавляем в план списания - materials_to_update[fitting_id] = planned_deduction + Decimal(quantity_used) + # Добавляем в план списания (остаемся с 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')) + price = fitting_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal cost = price * Decimal(quantity_used) fittings_total_cost += cost @@ -792,7 +914,7 @@ def sewing(): continue # Пропускаем эту запись брака # Проверка доступности с учетом уже запланированного - available_qty = defect_material.get('quantity', Decimal('0')) + 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 @@ -807,7 +929,7 @@ def sewing(): materials_to_update[material_id] = planned_deduction + quantity_deduct # Считаем стоимость брака - price = defect_material.get('price_per_unit', Decimal('0.00')) + price = defect_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal defect_cost = price * quantity_deduct # Готовим запись для лога брака @@ -833,14 +955,14 @@ def sewing(): for material_id, quantity_to_deduct in materials_to_update.items(): material_updated = False for i, mat in enumerate(current_materials): - if mat.get('id') == material_id: + 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('1'), rounding=ROUND_HALF_UP) # Округляем до целого + new_qty = new_qty.quantize(Decimal('0'), rounding=ROUND_HALF_UP) # Округляем до целого (0 знаков) # Убедимся, что не ушли в минус if new_qty < 0: new_qty = Decimal('0') @@ -862,7 +984,7 @@ def sewing(): 'id': uuid.uuid4().hex, 'cutting_task_id': cutting_task_id, 'product_name': sewn_product_name, - 'sewn_quantity': sewn_quantity, + 'sewn_quantity': sewn_quantity, # int 'fabric_id': cutting_task['fabric_id'], # Для справки 'fabric_name': cutting_task['fabric_name'], # Для справки 'fittings_consumed': fittings_consumed, # Список использованной фурнитуры @@ -874,7 +996,7 @@ def sewing(): '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') } @@ -887,13 +1009,13 @@ def sewing(): current_cutting_tasks = data.get('cutting_tasks', []) cutting_task_updated = False for i, task in enumerate(current_cutting_tasks): - if task.get('id') == cutting_task_id and task.get('status') == 'pending': + 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 task.get('id') == cutting_task_id: + elif isinstance(task, dict) and task.get('id') == cutting_task_id: # Задача найдена, но статус уже не pending (маловероятно из-за проверки выше) cutting_task_updated = True # Считаем, что обработали, раз нашли logging.warning(f"Попытка обновить статус для уже обработанного задания раскроя {cutting_task_id}.") @@ -915,9 +1037,7 @@ def sewing(): if 'defect_log' not in data: data['defect_log'] = [] data['defect_log'].extend(sewing_task['defects_reported']) # Добавляем те же объекты - data['materials'] = current_materials - data['cutting_tasks'] = current_cutting_tasks - + # data['materials'] и data['cutting_tasks'] уже обновлены save_data(data) flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' успешно зарегистрирован. Статус: Ожидает ОТК.", "success") if defects_reported: @@ -934,28 +1054,31 @@ def sewing(): # GET запрос: отображаем страницу # Готовим данные для шаблона tasks_for_template = [] - 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: - task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00')) - tasks_for_template.append(task_data) + 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: - 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) + 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: - m_data = find_item_by_id(m['id'], 'materials') - if m_data: - if m_data['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) + 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) @@ -965,7 +1088,11 @@ def sewing(): def qc_packing(): data = load_data() # Находим задания пошива, ожидающие ОТК - pending_qc_tasks = [t for t in data.get('sewing_tasks', []) if t.get('status') == 'pending_qc'] + 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': @@ -1000,6 +1127,7 @@ def qc_packing(): 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) @@ -1022,21 +1150,21 @@ def qc_packing(): if quantity_packed > 0: # Получаем связанные данные для расчета себестоимости cutting_task_id = sewing_task.get('cutting_task_id') - # Используем find_item_by_id, который вернет данные с Decimal + # Используем 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 благодаря find_item_by_id) + # Получаем стоимости из задач (уже в 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 # Избегаем деления на ноль @@ -1091,6 +1219,7 @@ def qc_packing(): 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')) @@ -1104,7 +1233,6 @@ def qc_packing(): 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 бракованного изделия @@ -1136,7 +1264,7 @@ def qc_packing(): sewing_task_updated = False current_sewing_tasks = data.get('sewing_tasks', []) for i, task in enumerate(current_sewing_tasks): - if task.get('id') == sewing_task_id: + 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 @@ -1161,9 +1289,8 @@ def qc_packing(): # Возможно, стоит откатить изменения или выдать более серьезное предупреждение flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger") - # --- Сохранение данных --- - data['sewing_tasks'] = current_sewing_tasks + # data['sewing_tasks'], data['qc_packing_items'], data['defect_log'] уже обновлены save_data(data) flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. " @@ -1184,19 +1311,21 @@ def qc_packing(): # GET запрос: отображаем страницу # Готовим список задач для шаблона, рассчитывая остаток tasks_for_template = [] - for task_id in [t['id'] for t in pending_qc_tasks]: - 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) + 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(): @@ -1210,7 +1339,7 @@ def clients_panel(): flash("Имя/Название организации и номер телефона обязательны.", "danger") return redirect(url_for('clients_panel')) - clients = load_client_data() + clients = load_client_data() # Загружает проверенные данные # Проверка на дубликат по номеру телефона (очищенному от нецифровых символов) normalized_phone = ''.join(filter(str.isdigit, phone)) if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone for c in clients): @@ -1222,53 +1351,68 @@ def clients_panel(): 'name': name, 'phone': phone, 'address': address if address else None, # Сохраняем None, если адрес пуст - 'history': [] # Инициализируем пустую историю + 'history': [] # Инициализируем пустую историю как список } clients.append(new_client) - save_client_data(clients) + save_client_data(clients) # Сохраняет проверенные данные flash(f"Клиент '{name}' успешно добавлен.", "success") upload_db_to_hf(CLIENT_DATA_FILE) # Бэкап данных клиентов return redirect(url_for('clients_panel')) - # GET запрос: отображение списка клиентов - clients_data = load_client_data() - clients_data.sort(key=lambda x: x.get('name','').lower()) # Сортировка по имени - - # Подготовка данных для шаблона: обработка истории - for client in clients_data: - # --- НАЧАЛО ИСПРАВЛЕНИЯ (гарантируем, что history - это список) --- - if 'history' not in client or not isinstance(client.get('history'), list): - client['history'] = [] - logging.warning(f"Обнаружен некорректный формат 'history' для клиента {client.get('id', 'UNKNOWN')} в GET запросе. Инициализировано пустым списком.") - # --- КОНЕЦ ИСПРАВЛЕНИЯ --- - - # Сортировка истории (теперь безопасно) и парсинг дат - if 'history' in client: # Проверка все еще имеет смысл для производительности - # Сортируем исходный список (не создаем копию для сортировки тут) - client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True) - # Добавляем datetime объекты для удобного отображения в шаблоне - for record in client['history']: - record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) - - html = BASE_TEMPLATE.replace('__TITLE__', "База клиентов").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS) - # Передаем обработанные данные в шаблон - return render_template_string(html, clients=clients_data) + # --- 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'] уже не нужна + + # Добавляем подробное логирование перед рендерингом + logging.debug(f"Data for clients_panel template: clients type={type(clients_data)}, length={len(clients_data)}") + for i, client in enumerate(clients_data): + hist = client.get('history') + items_in_hist_status = [] + if isinstance(hist, list): + for j, record in enumerate(hist): + items = record.get('items') + items_in_hist_status.append(f"rec{j}_items_type={type(items)}") + logging.debug(f"Client {i} ({client.get('id')}): history type={type(hist)}, details=[{', '.join(items_in_hist_status)}]") + + 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() + clients_data = load_client_data() # Загружаем проверенных клиентов config = data.get('config', {}) # Получаем актуальные данные с помощью find_item_by_id для всех списков - # Это гарантирует наличие преобразованных Decimal/int полей - all_materials = [find_item_by_id(m['id'], 'materials') for m in data.get('materials',[]) if find_item_by_id(m['id'], 'materials')] - all_cutting_tasks = [find_item_by_id(t['id'], 'cutting_tasks') for t in data.get('cutting_tasks',[]) if find_item_by_id(t['id'], 'cutting_tasks')] - all_sewing_tasks = [find_item_by_id(t['id'], 'sewing_tasks') for t in data.get('sewing_tasks',[]) if find_item_by_id(t['id'], 'sewing_tasks')] - all_packed_items = [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')] - all_defect_log = [find_item_by_id(d['log_id'], 'defect_log') for d in data.get('defect_log',[]) if find_item_by_id(d['log_id'], 'defect_log')] - all_expenses = [find_item_by_id(e['id'], 'expenses') for e in data.get('expenses',[]) if find_item_by_id(e['id'], 'expenses')] + # Используем list comprehensions с проверкой результата find_item_by_id + all_materials = [m for m_id in [m.get('id') for m in data.get('materials', []) if isinstance(m, dict)] if (m := find_item_by_id(m_id, 'materials')) is not None] + all_cutting_tasks = [t for t_id in [t.get('id') for t in data.get('cutting_tasks', []) if isinstance(t, dict)] if (t := find_item_by_id(t_id, 'cutting_tasks')) is not None] + all_sewing_tasks = [s for s_id in [s.get('id') for s in data.get('sewing_tasks', []) if isinstance(s, dict)] if (s := find_item_by_id(s_id, 'sewing_tasks')) is not None] + all_packed_items = [p for p_id in [p.get('id') for p in data.get('qc_packing_items', []) if isinstance(p, dict)] if (p := find_item_by_id(p_id, 'qc_packing_items')) is not None] + all_defect_log = [d for d_id in [d.get('log_id') for d in data.get('defect_log', []) if isinstance(d, dict)] if (d := find_item_by_id(d_id, 'defect_log')) is not None] + all_expenses = [e for e_id in [e.get('id') for e in data.get('expenses', []) if isinstance(e, dict)] if (e := find_item_by_id(e_id, 'expenses')) is not None] categories = data.get('categories', []) # --- Расчет сводных данных --- @@ -1339,7 +1483,7 @@ def dispatch_item(): return redirect(url_for('admin_panel') + '#dispatch-content') # Возвращаемся на вкладку отправки data = load_data() - clients = load_client_data() # Загружаем данные клиентов + clients = load_client_data() # Загружаем проверенные данные клиентов packed_item_ref = None packed_item_index = -1 @@ -1349,7 +1493,7 @@ def dispatch_item(): packed_items_list = data.get('qc_packing_items', []) for i, item in enumerate(packed_items_list): # Ищем по ID и проверяем статус - if item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': + if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': packed_item_ref = item # Сохраняем ссылку на объект для изменения packed_item_index = i item_found = True @@ -1375,12 +1519,11 @@ def dispatch_item(): # Ищем клиента в загруженных данных client_object_to_update = None - client_index_to_save = -1 # Индекс для сохранения обновленного клиента client_found = False - for i, cl in enumerate(clients): + # clients уже проверен на list, client на dict в load_client_data + for cl in clients: if cl.get('id') == client_id: client_object_to_update = cl # Ссылка на объект клиента для добавления истории - client_index_to_save = i client_name = cl.get('name', 'Имя не найдено') client_found = True break @@ -1397,25 +1540,24 @@ def dispatch_item(): # Добавляем запись в историю клиента history_entry = { - 'shipment_id': uuid.uuid4().hex, # Уникальный ID самой отправки + 'shipment_id': uuid.uuid4().hex, 'timestamp': dispatch_time_iso, - 'items': [ # Список товаров в этой отправке (пока только один) + # --- УБЕДИМСЯ, ЧТО 'items' - ЭТО СПИСОК (доп. проверка) --- + 'items': [ { - 'product_name': packed_item_ref['product_name'], - 'quantity': packed_item_ref['quantity'] # Используем int из данных + 'product_name': packed_item_ref.get('product_name', 'N/A'), # Используем .get + 'quantity': packed_item_ref.get('quantity', 0) # Используем .get } - ], - 'packed_item_id': item_id # Ссылка на ID упакованной партии + ] if packed_item_ref else [], + 'packed_item_id': item_id } - # Убедимся, что поле history существует и является списком - if 'history' not in client_object_to_update or not isinstance(client_object_to_update.get('history'), list): - client_object_to_update['history'] = [] - logging.warning(f"Инициализирован список history для клиента {client_id} при добавлении записи.") + # Добавляем запись в history клиента + # load_client_data уже гарантирует, что history является списком client_object_to_update['history'].append(history_entry) client_data_changed = True # Устанавливаем флаг для сохранения файла клиентов - logging.info(f"Товар {item_id} ({packed_item_ref['product_name']}) помечен как 'shipped_client' для {client_name} ({client_id}). Запись добавлена в историю клиента.") + logging.info(f"Товар {item_id} ({packed_item_ref.get('product_name', 'N/A')}) помечен как 'shipped_client' для {client_name} ({client_id}). Запись добавлена в историю клиента.") destination_display_text = f"клиенту '{client_name}'" @@ -1424,7 +1566,7 @@ def dispatch_item(): packed_item_ref['status'] = 'shipped_dor_doi' shipment_details['destination'] = 'Торговая точка Дордой' # Пример названия packed_item_ref['shipment_details'] = shipment_details - logging.info(f"Товар {item_id} ({packed_item_ref['product_name']}) помечен как 'shipped_dor_doi'.") + logging.info(f"Товар {item_id} ({packed_item_ref.get('product_name', 'N/A')}) помечен как 'shipped_dor_doi'.") destination_display_text = "на Торговую точку Дордой" else: @@ -1439,14 +1581,14 @@ def dispatch_item(): # Если данные клиента менялись (добавлялась история), сохраняем файл клиентов if client_data_changed: - save_client_data(clients) + save_client_data(clients) # Сохраняем проверенный список клиентов upload_db_to_hf(CLIENT_DATA_FILE) # Бэкап файла клиентов logging.info(f"Данные клиента {client_id} сохранены после добавления истории отправки.") else: logging.info(f"Данные клиентов не изменялись при отправке товара {item_id} (отправка не клиенту).") - flash(f"Товар '{packed_item_ref['product_name']}' ({packed_item_ref['quantity']} шт.) успешно отправлен {destination_display_text}.", "success") + flash(f"Товар '{packed_item_ref.get('product_name', 'N/A')}' ({packed_item_ref.get('quantity', 0)} шт.) успешно отправлен {destination_display_text}.", "success") return redirect(url_for('admin_panel') + '#dispatch-content') # --- Остальные маршруты админ-панели --- @@ -1481,14 +1623,14 @@ def add_expense(): if not description or not amount_str: flash("Необходимо заполнить описание и сумму расхода.", "warning") - return redirect(url_for('admin_panel')) # Возвращаемся на админку (вкладка расходов) + return redirect(url_for('admin_panel') + '#expenses-report-content') # Возвращаемся на админку, вкладка расходов try: amount = to_decimal(amount_str) if amount <= 0: raise ValueError("Сумма должна быть > 0") except (InvalidOperation, ValueError): flash("Некорректное значение суммы расхода. Введите положительное число.", "warning") - return redirect(url_for('admin_panel')) + return redirect(url_for('admin_panel') + '#expenses-report-content') if 'expenses' not in data or not isinstance(data['expenses'], list): data['expenses'] = [] # Инициализируем, если отсутствует или не список @@ -1516,10 +1658,10 @@ def add_category(): return redirect(url_for('admin_panel')) # Возврат на админку # Проверка на существование (без учета регистра) - if new_category_name.lower() not in [c.lower() for c in categories]: + if new_category_name.lower() not in [c.lower() for c in categories if isinstance(c, str)]: # Добавили isinstance categories.append(new_category_name) # Обновляем список категорий в данных и сортируем - data['categories'] = sorted(list(set(categories)), key=str.lower) + data['categories'] = sorted(list(set(c for c in categories if isinstance(c, str))), key=str.lower) # Фильтруем не-строки перед set save_data(data) flash(f"Категория '{new_category_name}' успешно добавлена.", "success") upload_db_to_hf(DATA_FILE) # Бэкап @@ -1545,29 +1687,28 @@ def delete_category(): # Находим точное имя категории (с учетом регистра) для удаления original_category_name = None category_found = False - for cat in categories: + 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: - categories.remove(original_category_name) # Удаляем из списка - data['categories'] = sorted(categories, key=str.lower) # Сохраняем отсортированный список + 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 mat.get('category', 'Без категории') == original_category_name: + if isinstance(mat, dict) and mat.get('category', 'Без категории') == original_category_name: mat['category'] = 'Без категории' # Переносим в базовую категорию mat['timestamp_last_updated'] = update_time materials_updated_count += 1 - if materials_updated_count > 0: - data['materials'] = current_materials # Сохраняем изменения в материалах - + # data['materials'] уже обновлен, если были изменения save_data(data) flash(f"Категория '{original_category_name}' успешно удалена.", "success") if materials_updated_count > 0: @@ -1767,38 +1908,41 @@ def reports(): # Упакованные изделия (фильтруем по timestamp_packed) filtered_packed_items = [] all_packed_items_raw = data.get('qc_packing_items', []) - for item_id in [i.get('id') for i in all_packed_items_raw]: - item_data = find_item_by_id(item_id, 'qc_packing_items') # Получаем с Decimal - 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) + 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') # Получаем с Decimal + 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) # Брак (фильтруем по timestamp) all_defect_log_raw = data.get('defect_log', []) filtered_defects = [] - for defect_id in [d.get('log_id') for d in all_defect_log_raw]: - defect_data = find_item_by_id(defect_id, 'defect_log') # Получаем с Decimal - 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) + 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') # Получаем с Decimal + 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) # Дополнительные расходы (фильтруем по timestamp) all_expenses_raw = data.get('expenses', []) filtered_expenses = [] - for expense_id in [e.get('id') for e in all_expenses_raw]: - expense_data = find_item_by_id(expense_id, 'expenses') # Получаем с Decimal - 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) + 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') # Получаем с Decimal + 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) @@ -1860,7 +2004,6 @@ def reports(): # --- HTML Шаблоны --- -# (ВСТАВЬТЕ ВСЕ ШАБЛОНЫ HTML И СКРИПТЫ ИЗ ПРЕДЫДУЩЕГО ОТВЕТА ЗДЕСЬ, УБЕДИТЕСЬ ЧТО ИЗМЕНЕНИЯ В CLIENTS_CONTENT ПРИМЕНЕНЫ) # ОБЫЧНЫЙ Базовый шаблон (с навигацией) BASE_TEMPLATE = """ @@ -2006,6 +2149,29 @@ function getCurrentTimeFromFooter() { const footer = document.querySelector('.fo // Вспомогательные JS функции для статусов (могут быть полезны для динамики) function getStatusTextJS(statusKey) { const map = {'pending': 'Ожидает пошива','completed': 'Завершено','pending_qc': 'Ожидает ОТК','packed_ready_to_ship': 'Готово к отправке','shipped_client': 'Отправлено клиенту','shipped_dor_doi': 'Отправлено на Дордой'}; return map[statusKey] || statusKey; } function getStatusClassJS(statusKey) { const map = {'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','shipped_client': 'status-shipped_client text-shipped-client','shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi'}; return map[statusKey] || ''; } +// Глобальная функция сортировки таблиц (вызывается из th onclick) +function sortTable(columnIndex, tableId, isNumeric = false) { + const table = document.getElementById(tableId); if (!table) return; + const tbody = table.querySelector('tbody'); const headerRow = table.querySelector('thead tr'); if (!tbody || !headerRow) return; + const rows = Array.from(tbody.querySelectorAll('tr:not(.no-result-row)')); if (rows.length < 2) return; + const headerCell = headerRow.querySelector(`th:nth-child(${columnIndex + 1})`); if (!headerCell) return; + let currentDir = headerCell.dataset.sortDir || 'asc'; let newDir = currentDir === 'asc' ? 'desc' : 'asc'; + headerRow.querySelectorAll('th').forEach((th, index) => { + const icon = th.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down'); + if (icon) { icon.className = (index === columnIndex) ? `fas fa-sort-${newDir === 'asc' ? 'up' : 'down'}` : 'fas fa-sort'; } + th.dataset.sortDir = (index === columnIndex) ? newDir : ''; + }); + rows.sort((a, b) => { + let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`); let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`); + let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : ''; let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : ''; + let comparison = 0; + if (isNumeric) { + valA = parseFloat(String(valA).replace(/\s/g, '').replace(',', '.')) || 0; valB = parseFloat(String(valB).replace(/\s/g, '').replace(',', '.')) || 0; comparison = valA - valB; + } else { valA = valA.toLowerCase(); valB = valB.toLowerCase(); comparison = valA.localeCompare(valB, 'ru'); } + return newDir === 'asc' ? comparison : -comparison; + }); + rows.forEach(row => tbody.appendChild(row)); +} __SCRIPTS__ @@ -2189,14 +2355,14 @@ PROCUREMENT_SCRIPTS = """ newRow.querySelectorAll('input[type="text"], input[type="number"]').forEach(i => i.value = ''); newRow.querySelectorAll('select').forEach(s => { if (s.name === 'item_unit[]') s.value = 'м'; else if (s.name === 'item_type[]') s.value = 'fabric'; else if (s.name === 'item_category[]') s.value = 'Без категории'; else s.selectedIndex = 0; }); const nci = newRow.querySelector('.new-category-input'); if (nci) { nci.style.display = 'none'; nci.required = false; nci.value = ''; } - const rb = newRow.querySelector('.remove-row-btn'); if (rb) rb.style.display = 'inline-block'; + const rb = newRow.querySelector('.remove-row-btn'); if (rb) rb.style.display = 'inline-block'; // Показываем кнопку удаления для новых строк container.appendChild(newRow); attachCategoryChangeEvent(newRow); } function removeRow(button) { const row = button.closest('.dynamic-row'); if (row) row.remove(); } function handleCategoryChange(selectElement) { const row = selectElement.closest('.dynamic-row'); if (!row) return; const nci = row.querySelector('.new-category-input'); if (!nci) return; const isNew = selectElement.value === '__new__'; nci.style.display = isNew ? 'block' : 'none'; nci.required = isNew; if (!isNew) nci.value = ''; } function attachCategoryChangeEvent(rowElement) { const cs = rowElement.querySelector('.category-select'); if(cs) { cs.removeEventListener('change', categoryChangeHandler); cs.addEventListener('change', categoryChangeHandler); } } function categoryChangeHandler() { handleCategoryChange(this); } - document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.dynamic-row').forEach(row => { attachCategoryChangeEvent(row); const cs = row.querySelector('.category-select'); if (cs) handleCategoryChange(cs); }); if (!document.querySelector('#material-rows .dynamic-row')) addRow(); }); + document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.dynamic-row').forEach(row => { attachCategoryChangeEvent(row); const cs = row.querySelector('.category-select'); if (cs) handleCategoryChange(cs); }); if (!document.querySelector('#material-rows .dynamic-row')) addRow(); }); // Добавляем одну строку, если пусто """ @@ -2445,6 +2611,8 @@ SEWING_SCRIPTS = """ showTaskDetails(); document.querySelectorAll('.dynamic-fitting-row').forEach(row => { attachFittingChangeEvent(row); const fittingSelect = row.querySelector('.fitting-select'); if (fittingSelect && fittingSelect.value) handleFittingChange(fittingSelect); }); document.querySelectorAll('.dynamic-defect-row').forEach(row => { attachDefectChangeEvent(row); const defectSelect = row.querySelector('.defect-material-select'); if (defectSelect && defectSelect.value) handleDefectChange(defectSelect); }); + if (!document.querySelector('#fittings-rows .dynamic-fitting-row')) addFittingRow(); // Добавляем одну пустую строку, если нет ни одной + if (!document.querySelector('#defect-rows .dynamic-defect-row')) addDefectRow(); // Добавляем одну пустую строку, если нет ни одной }); """ @@ -2629,26 +2797,32 @@ CLIENTS_CONTENT = """