diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -200,7 +200,7 @@ def periodic_backup(): """Периодически вызывает upload_db_to_hf для обоих файлов.""" logging.info("Запуск потока периодического резервного копирования.") while True: - time.sleep(1800) + time.sleep(1800) # Каждые 30 минут logging.info("Запуск планового резервного копирования...") with data_lock: if os.path.exists(DATA_FILE): upload_db_to_hf(DATA_FILE) @@ -224,56 +224,104 @@ def parse_iso_datetime(timestamp_str): try: try: dt = datetime.fromisoformat(timestamp_str) except ValueError: + # Пытаемся убрать миллисекунды, если они есть и мешают if '.' in timestamp_str: timestamp_str = timestamp_str.split('.', 1)[0] dt = datetime.fromisoformat(timestamp_str) - if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: return pytz.utc.localize(dt).astimezone(BISHKEK_TZ) + # Если временная зона отсутствует, считаем 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) as e: logging.warning(f"Не удалось разобрать дату: {timestamp_str}. Ошибка: {e}"); return None def find_item_by_id(item_id, item_list_name): """Обобщенная функция для поиска элемента по ID в основном списке данных.""" - data = load_data() + data = load_data() # Загружаем свежие данные при каждом поиске items = data.get(item_list_name, []) for item in items: + # Проверяем как 'id', так и 'log_id' (для defect_log) if item.get('id') == item_id or item.get('log_id') == item_id: - item_copy = item.copy() - decimal_fields, int_fields = [], [] - if item_list_name == 'materials': decimal_fields, int_fields = ['quantity', 'price_per_unit'], ['items_per_unit'] - elif item_list_name == 'cutting_tasks': decimal_fields, int_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost'], ['cut_items_quantity'] + item_copy = item.copy() # Возвращаем копию, чтобы не изменять исходные данные + + # --- Преобразование типов --- + 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, int_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost'], ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] - if 'fittings_consumed' in item_copy: - for f in item_copy['fittings_consumed']: f['quantity_used'], f['cost'] = int(to_decimal(f.get('quantity_used', '0'))), to_decimal(f.get('cost', '0.00')) - if 'defects_reported' in item_copy: + 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, d_type, d['cost'] = d.get('quantity', '0'), d.get('type'), to_decimal(d.get('cost', '0.00')) - if d_type == 'fabric': d['quantity'] = to_decimal(qty) - elif d_type in ['fittings', 'finished_product']: d['quantity'] = int(to_decimal(qty)) if qty else 0 - else: d['quantity'] = 0 - elif item_list_name == 'qc_packing_items': decimal_fields, int_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'], ['quantity'] - elif item_list_name == 'expenses': decimal_fields, int_fields = ['amount'], [] + qty_str = d.get('quantity', '0') + defect_type = d.get('type') + d['cost'] = to_decimal(d.get('cost', '0.00')) + # Преобразование quantity в зависимости от типа брака + if defect_type == 'fabric': + d['quantity'] = to_decimal(qty_str) # Оставляем Decimal для ткани + elif defect_type in ['fittings', 'finished_product']: + try: d['quantity'] = int(to_decimal(qty_str)) # Преобразуем в int + except (InvalidOperation, ValueError): d['quantity'] = 0 + else: d['quantity'] = 0 # Неизвестный тип + elif item_list_name == 'qc_packing_items': + decimal_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'] + int_fields = ['quantity'] + elif item_list_name == 'expenses': + decimal_fields = ['amount'] elif item_list_name == 'defect_log': - decimal_fields, qty, d_type = ['cost'], item_copy.get('quantity', '0'), item_copy.get('type') + # 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')) - if d_type == 'fabric': item_copy['quantity_view'], item_copy['quantity_raw'] = f"{to_decimal(str(qty)):.2f}".replace('.', ','), to_decimal(str(qty)) - elif d_type in ['fittings', 'finished_product']: - try: qty_int = int(to_decimal(str(qty))); item_copy['quantity_view'], item_copy['quantity_raw'] = str(qty_int), qty_int - except (InvalidOperation, ValueError): item_copy['quantity_view'], item_copy['quantity_raw'] = '0', 0 - else: item_copy['quantity_view'], item_copy['quantity_raw'] = str(qty), qty - - for field in decimal_fields: item_copy[field] = to_decimal(item_copy.get(field)) - for field in int_fields: item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) + # Добавим поля для отображения и сырое значение 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'))) + return item_copy - return None + return None # Элемент не найден def find_client_by_id(client_id): """Ищет клиента по ID в базе клиентов.""" - clients = load_client_data() + clients = load_client_data() # Загружаем свежие данные for client in clients: if client.get('id') == client_id: client_copy = client.copy() - if 'history' in client_copy: - for record in client_copy['history']: record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) + # Преобразуем таймстемпы истории для удобства + if 'history' in client_copy and isinstance(client_copy['history'], list): + for record in client_copy['history']: + record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) return client_copy return None @@ -282,520 +330,1533 @@ def format_currency_py(value): """Formats a Decimal or string representation as currency (Python side).""" try: number = to_decimal(value) + # Формат с пробелом как разделителем тысяч и запятой как десятичным разделителем formatted_num = f"{number:,.2f}".replace(",", "TEMP_SPACE").replace(".", ",").replace("TEMP_SPACE", " ") return formatted_num - except (InvalidOperation, TypeError, ValueError): return "0,00" + except (InvalidOperation, TypeError, ValueError): + return "0,00" # Возвращаем строку по умолчанию def format_integer_py(value): """Formats a Decimal or string representation as an integer string (Python side).""" try: + # Преобразуем в Decimal, затем в целое с округлением number = to_decimal(value).to_integral_value(rounding=ROUND_HALF_UP) + # Формат с пробелом как разделителем тысяч return f"{number:,}".replace(",", " ") - except (InvalidOperation, TypeError, ValueError): return "0" + except (InvalidOperation, TypeError, ValueError): + return "0" # Возвращаем строку по умолчанию # --- Python Helper Functions for Status Display --- def getStatusText(statusKey): """Возвращает текстовое представление статуса на русском.""" - statusMap = {'pending': 'Ожидает пошива', 'completed': 'Завершено', 'pending_qc': 'Ожидает ОТК', - 'packed_ready_to_ship': 'Готово к отправке', 'shipped_client': 'Отправлено клиенту', - 'shipped_dor_doi': 'Отправлено на Дордой'} - return statusMap.get(statusKey, statusKey) + statusMap = { + 'pending': 'Ожидает пошива', + 'completed': 'Завершено', + 'pending_qc': 'Ожидает ОТК', + 'packed_ready_to_ship': 'Готово к отправке', + 'shipped_client': 'Отправлено клиенту', + 'shipped_dor_doi': 'Отправлено на Дордой' + } + return statusMap.get(statusKey, statusKey) # Возвращаем ключ, если статус неизвестен def getStatusClass(statusKey): """Возвращает CSS классы для стилизации статуса.""" - classMap = {'pending': 'status-pending text-info', 'completed': 'status-completed text-success', - 'pending_qc': 'status-pending_qc text-warning', - 'packed_ready_to_ship': 'status-packed_ready_to_ship text-ready', - 'shipped_client': 'status-shipped_client text-shipped-client', - 'shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi'} - return classMap.get(statusKey, '') + classMap = { + 'pending': 'status-pending text-info', + 'completed': 'status-completed text-success', + 'pending_qc': 'status-pending_qc text-warning', + 'packed_ready_to_ship': 'status-packed_ready_to_ship text-ready', # Использует .text-ready + 'shipped_client': 'status-shipped_client text-shipped-client', # Использует .text-shipped-client + 'shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi' # Использует .text-shipped-dordoi + } + return classMap.get(statusKey, '') # Возвращаем пустую строку, если статус неизвестен # --- Маршруты Flask --- @app.route('/') -def index(): return redirect(url_for('admin_panel')) +def index(): + # Перенаправляем на админ-панель по умолчанию + return redirect(url_for('admin_panel')) # 1. Маршрут "Закуп" @app.route('/procurement', methods=['GET', 'POST']) def procurement(): data = load_data() categories = data.get('categories', []) + if request.method == 'POST': try: - materials_to_add = []; valid_items = 0 + materials_to_add = [] + valid_items_processed = 0 # Счетчик успешно обработанных строк + item_names = request.form.getlist('item_name[]') + # Проверка, что хотя бы одна строка была отправлена и не пуста if not item_names or all(not name.strip() for name in item_names): - flash("Не добавлено ни одного товара.", "warning"); return redirect(url_for('procurement')) - item_quantities = request.form.getlist('item_quantity[]'); item_units = request.form.getlist('item_unit[]') - item_prices = request.form.getlist('item_price_per_unit[]'); item_per_unit = request.form.getlist('item_per_unit[]') - item_types = request.form.getlist('item_type[]'); item_cats = request.form.getlist('item_category[]') - new_cats = request.form.getlist('item_new_category[]') - proc_time = get_current_time().isoformat() - mats = data.get('materials', []) + flash("Не добавлено ни одного товара. Заполните хотя бы одну строку.", "warning") + return redirect(url_for('procurement')) + + item_quantities = request.form.getlist('item_quantity[]') + item_units = request.form.getlist('item_unit[]') + item_prices = request.form.getlist('item_price_per_unit[]') + item_per_unit = request.form.getlist('item_per_unit[]') # Количество изделий на единицу (для фурнитуры) + item_types = request.form.getlist('item_type[]') + item_categories = request.form.getlist('item_category[]') + item_new_categories = request.form.getlist('item_new_category[]') + + procurement_time = get_current_time().isoformat() + current_materials = data.get('materials', []) # Получаем текущий список материалов + for i in range(len(item_names)): name = item_names[i].strip() - if not name and not item_quantities[i] and not item_prices[i] and not item_cats[i] and not new_cats[i]: continue - qty_s, unit, price_s, per_u_s, itype, cat, ncat = item_quantities[i], item_units[i], item_prices[i], item_per_unit[i], item_types[i], item_cats[i], new_cats[i].strip() - if not name or not qty_s or not unit or not price_s or not itype: flash(f"Ошибка в стр {i+1}: Заполните название, кол-во, цену, ед. и тип.", "danger"); continue - try: qty, price = to_decimal(qty_s), to_decimal(price_s) - except InvalidOperation: flash(f"Ошибка в стр {i+1}: Некорректный формат числа.", "danger"); continue - if qty <= 0: flash(f"Ошибка в стр {i+1}: Кол-во > 0.", "danger"); continue - if price < 0: flash(f"Ошибка в стр {i+1}: Цена >= 0.", "danger"); continue - ipu = int(to_decimal(per_u_s).to_integral_value()) if per_u_s else 0 - if ipu < 0: ipu = 0 - fcat = ncat if ncat else (cat if cat and cat != "__new__" else "Без категории") - if ncat and fcat not in categories: categories.append(fcat) - ex_idx = next((idx for idx, m in enumerate(mats) if m['name'].lower() == name.lower() and m['type'] == itype and m.get('category', 'Без категории') == fcat), -1) - if ex_idx != -1: - mats[ex_idx]['price_per_unit'] = str(price) - cur_q = to_decimal(mats[ex_idx].get('quantity', '0')); new_q = cur_q + qty - mats[ex_idx]['quantity'], mats[ex_idx]['unit'], mats[ex_idx]['items_per_unit'], mats[ex_idx]['timestamp_last_updated'] = str(new_q), unit, ipu, proc_time - logging.info(f"Обновлен: '{name}'. Новое кол-во: {new_q}, Цена: {price}") - valid_items += 1 + quantity_str = item_quantities[i] + unit = item_units[i] + price_str = item_prices[i] + items_per_unit_str = item_per_unit[i] + item_type = item_types[i] + category = item_categories[i] + new_category = item_new_categories[i].strip() + + # Пропускаем пустые строки, которые могли быть добавлены динамически + if not name and not quantity_str and not price_str and not category and not new_category: + continue + + # Валидация обязательных полей для НЕпустой строки + if not name or not quantity_str or not unit or not price_str or not item_type: + flash(f"Ошибка в строке {i+1}: Необходимо заполнить название, количество, единицу измерения, цену за единицу и тип.", "danger") + continue # Пропускаем эту строку, но продолжаем обработку остальных + + # Преобразование и валидация чисел + try: + quantity = to_decimal(quantity_str) + price = to_decimal(price_str) + except InvalidOperation: + flash(f"Ошибка в строке {i+1}: Некорректный формат числа для количества или цены.", "danger") + continue + if quantity <= 0: + flash(f"Ошибка в строке {i+1}: Количество должно быть б��льше нуля.", "danger") + continue + if price < 0: + flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger") + continue + + # Обработка "На ед." (items_per_unit) + items_per_unit = 0 + if items_per_unit_str: + try: items_per_unit = int(to_decimal(items_per_unit_str).to_integral_value()) + except (InvalidOperation, ValueError): flash(f"Предупреждение в строке {i+1}: Некорректное значение 'На ед.', установлено 0.", "warning"); items_per_unit = 0 + if items_per_unit < 0: items_per_unit = 0 + + # Определение категории + final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории") + # Добавляем новую категорию в общий список, если её там нет + if new_category and final_category not in categories: + categories.append(final_category) + + # Поиск существующего материала (по названию, типу и категории) + existing_material_index = -1 + for idx, mat in enumerate(current_materials): + # Сравниваем lowercase для имени и учитываем тип и категорию + if mat.get('name','').lower() == name.lower() and \ + mat.get('type') == item_type and \ + mat.get('category', 'Без категории') == final_category: + existing_material_index = idx + break + + if existing_material_index != -1: + # --- Обновляем существующий материал --- + existing_material = current_materials[existing_material_index] + # Обновляем цену (берем последнюю закупочную) + existing_material['price_per_unit'] = str(price) + # Добавляем количество + current_quantity = to_decimal(existing_material.get('quantity', '0')) + new_quantity = current_quantity + quantity + existing_material['quantity'] = str(new_quantity) + # Обновляем единицу измерения (на всякий случай) + existing_material['unit'] = unit + # Обновляем items_per_unit + existing_material['items_per_unit'] = items_per_unit + # Обновляем время последнего обновления + existing_material['timestamp_last_updated'] = procurement_time + logging.info(f"Материал '{name}' обновлен. Новое количество: {new_quantity}, Цена: {price}, Категория: {final_category}") + valid_items_processed += 1 + else: + # --- Добавляем новый материал --- + new_material = { + 'id': uuid.uuid4().hex, + 'name': name, + 'quantity': str(quantity), + 'unit': unit, + 'price_per_unit': str(price), + 'items_per_unit': items_per_unit, # Добавляем поле + 'type': item_type, + 'category': final_category, + 'timestamp_added': procurement_time, + 'timestamp_last_updated': procurement_time + } + materials_to_add.append(new_material) + logging.info(f"Новый материал '{name}' добавлен. Количество: {quantity}, Цена: {price}, Категория: {final_category}") + valid_items_processed += 1 + + # Сохраняем данные, если хотя бы одна позиция была успешно обработана + if valid_items_processed > 0 : + if materials_to_add: + data['materials'].extend(materials_to_add) # Добавляем новые else: - new_mat = {'id': uuid.uuid4().hex, 'name': name, 'quantity': str(qty), 'unit': unit, 'price_per_unit': str(price), - 'items_per_unit': ipu, 'type': itype, 'category': fcat, 'timestamp_added': proc_time, 'timestamp_last_updated': proc_time} - materials_to_add.append(new_mat) - logging.info(f"Добавлен: {name} ({qty} {unit}, Цена: {price})") - valid_items += 1 - if valid_items > 0 : - if materials_to_add: data['materials'].extend(materials_to_add) - else: data['materials'] = mats + # Если добавлялись только обновления, перезаписываем измененный current_materials + data['materials'] = current_materials + # Обновляем и сортируем список категорий data['categories'] = sorted(list(set(categories)), key=str.lower) - save_data(data); flash(f"Закуп зарегистрирован! Обработано {valid_items} позиций.", "success"); upload_db_to_hf(DATA_FILE) - else: flash("Не было добавлено/обновлено ни одной валидной позиции.", "warning") + save_data(data) + flash(f"Закуп успешно зарегистрирован! Обработано {valid_items_processed} позиций.", "success") + upload_db_to_hf(DATA_FILE) # Запускаем бэкап + else: + # Если были ошибки во всех строках или все строки были пустые/невалидные + flash("Не было добавлено или обновлено ни одной валидной позиции.", "warning") + return redirect(url_for('procurement')) - except Exception as e: logging.error(f"Ошибка закупа: {e}", exc_info=True); flash(f"Ошибка обработки закупа: {e}", "danger") + + except Exception as e: + logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger") + return redirect(url_for('procurement')) + + # GET запрос: отображаем страницу + # Добавляем форматированные строки для отображения в шаблоне + materials_display = [] + for m_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) + html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) - return render_template_string(html, categories=categories) + return render_template_string(html, categories=categories, materials_display=materials_display) # 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] + # Фильтруем материалы, оставляем только ткани с положительным количеством + fabrics = [m for m in data.get('materials', []) + if m.get('type') == 'fabric' and to_decimal(m.get('quantity', '0')) > 0] config = data.get('config', {}) + if request.method == 'POST': try: - f_id, qty_s, used_s = request.form.get('fabric_id'), request.form.get('cut_items_quantity'), request.form.get('fabric_used') - if not f_id or not qty_s or not used_s: flash("Заполните все поля.", "danger"); return redirect(url_for('cutting')) - fab = find_item_by_id(f_id, 'materials') - if not fab: flash("Ткань не найдена.", "danger"); return redirect(url_for('cutting')) - try: qty = int(to_decimal(qty_s).to_integral_value()); assert qty > 0 - except: flash("Кол-во изделий: целое > 0.", "danger"); return redirect(url_for('cutting')) - try: used = to_decimal(used_s); assert used > 0 - except: flash("Расход ткани: число > 0.", "danger"); return redirect(url_for('cutting')) - avail = to_decimal(fab.get('quantity', '0')) - if used > avail: flash(f"Недостаточно ткани '{fab['name']}'. В наличии: {format_currency_py(avail)}, требуется: {format_currency_py(used)}.", "danger"); return redirect(url_for('cutting')) - price, cost = to_decimal(fab.get('price_per_unit', '0')), used * to_decimal(fab.get('price_per_unit', '0')) - sal_c, sal_cost = to_decimal(config.get('salary_cutter_per_unit', '0')), Decimal(qty) * to_decimal(config.get('salary_cutter_per_unit', '0')) - ctime = get_current_time().isoformat() - task = {'id': uuid.uuid4().hex, 'fabric_id': f_id, 'fabric_name': fab['name'], 'fabric_unit': fab['unit'], - 'cut_items_quantity': qty, 'fabric_used': str(used), 'status': 'pending', 'timestamp_created': ctime, - 'timestamp_completed': None, 'material_cost': str(cost), 'cutting_salary_cost': str(sal_cost)} - new_avail = avail - used; updated = False - mats = data.get('materials', []) - for i, m in enumerate(mats): - if m.get('id') == f_id: mats[i]['quantity'], mats[i]['timestamp_last_updated'], updated = str(new_avail.quantize(Decimal('0.01'))), ctime, True; break - if not updated: flash(f"Критическая ошибка обновления ткани {fab['name']}.", "danger"); return redirect(url_for('cutting')) + fabric_id = request.form.get('fabric_id') + cut_items_quantity_str = request.form.get('cut_items_quantity') + fabric_used_str = request.form.get('fabric_used') + + # Валидация входных данных + if not fabric_id or not cut_items_quantity_str or not fabric_used_str: + flash("Необходимо выбрать ткань и заполнить все поля.", "danger") + return redirect(url_for('cutting')) + + # Ищем выбранную ткань в данных + fabric_material = find_item_by_id(fabric_id, 'materials') + if not fabric_material: + flash("Выбранная ткань не найдена в базе данных.", "danger") + return redirect(url_for('cutting')) + + # Преобразование и валидация чисел + try: + cut_items_quantity = int(to_decimal(cut_items_quantity_str).to_integral_value()) + if cut_items_quantity <= 0: raise ValueError("Количество должно быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное количество раскроенных изделий. Введите целое положительное число.", "danger") + return redirect(url_for('cutting')) + + try: + fabric_used = to_decimal(fabric_used_str) + if fabric_used <= 0: raise ValueError("Расход ткани должен быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное значение использованной ткани. Введите положительное число.", "danger") + return redirect(url_for('cutting')) + + # Проверка наличия достаточного количества ткани + available_quantity = fabric_material.get('quantity', Decimal('0.00')) # find_item_by_id уже вернул Decimal + if fabric_used > available_quantity: + flash(f"Недостаточно ткани '{fabric_material['name']}'. " + f"В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, " + f"требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger") + return redirect(url_for('cutting')) + + # Расчет стоимостей (на основе текущих данных) + price_per_unit = fabric_material.get('price_per_unit', Decimal('0.00')) + material_cost = fabric_used * price_per_unit + + salary_cutter_per_unit = to_decimal(config.get('salary_cutter_per_unit', '0.00')) + cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter_per_unit + + # Создание записи о задании на раскрой + creation_time = get_current_time().isoformat() + cutting_task = { + 'id': uuid.uuid4().hex, + 'fabric_id': fabric_id, + 'fabric_name': fabric_material['name'], # Сохраняем имя для удобства + 'fabric_unit': fabric_material['unit'], # Сохраняем единицу измерения + 'cut_items_quantity': cut_items_quantity, + 'fabric_used': str(fabric_used), # Сохраняем как строку + 'status': 'pending', # Начальный статус - ожидает пошива + 'timestamp_created': creation_time, + 'timestamp_completed': None, # Время завершения (когда начнется пошив) + 'material_cost': str(material_cost), # Стоимость израсходованной ткани + 'cutting_salary_cost': str(cutting_salary_cost) # Стоимость работы раскройщика + } + + # Обновл��ние остатка ткани + new_available_quantity = available_quantity - fabric_used + material_updated = False + current_materials = data.get('materials', []) + for i, mat in enumerate(current_materials): + if mat.get('id') == fabric_id: + # Обновляем количество и время последнего изменения + current_materials[i]['quantity'] = str(new_available_quantity.quantize(Decimal('0.01'))) # Округляем до 2 знаков + current_materials[i]['timestamp_last_updated'] = creation_time + material_updated = True + break + + if not material_updated: + # Это не должно произойти, если find_item_by_id сработал, но на всякий случай + flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger") + return redirect(url_for('cutting')) + + # Добавление задания в список и сохранение данных if 'cutting_tasks' not in data: data['cutting_tasks'] = [] - data['cutting_tasks'].append(task); data['materials'] = mats - save_data(data); flash(f"Задание на раскрой для {qty} ед. из '{fab['name']}' создано. Статус: Ожидает пошива.", "success"); upload_db_to_hf(DATA_FILE) + data['cutting_tasks'].append(cutting_task) + data['materials'] = current_materials # Сохраняем обновленный список материалов + save_data(data) + + flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап return redirect(url_for('cutting')) - except Exception as e: logging.error(f"Ошибка раскроя: {e}", exc_info=True); flash(f"Ошибка обработки раскроя: {e}", "danger") - fabrics_dec = [] + + except Exception as e: + logging.error(f"Ошибка при регистрации раскроя: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при регистрации раскроя: {e}", "danger") + return redirect(url_for('cutting')) + + # GET запрос: отображаем страницу + # Преобразуем данные о ткани для отображения + fabrics_display = [] for f in fabrics: - 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_dec.append(f_copy) + 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_dec) + return render_template_string(html, fabrics=fabrics_display) # 3. Маршрут "Пошив" @app.route('/sewing', methods=['GET', 'POST']) def sewing(): data = load_data() - pend_tasks = [t for t in data.get('cutting_tasks', []) if t.get('status') == 'pending'] - fittings = [m for m in data.get('materials', []) if m.get('type') == 'fittings' and to_decimal(m.get('quantity', '0')) > 0] - all_mats = data.get('materials', []) + # Находим задания раскроя, ожидающие пошива + pending_cutting_tasks = [t for t in data.get('cutting_tasks', []) if t.get('status') == 'pending'] + # Находим доступную фурнитуру + available_fittings = [m for m in data.get('materials', []) + if m.get('type') == 'fittings' and to_decimal(m.get('quantity', '0')) > 0] + # Все материалы (для выбора брака) + all_materials = data.get('materials', []) config = data.get('config', {}) + if request.method == 'POST': try: - cut_id, prod_name, qty_s = request.form.get('cutting_task_id'), request.form.get('sewn_product_name', '').strip(), request.form.get('sewn_quantity') - fit_ids, fit_qtys = request.form.getlist('fitting_ids[]'), request.form.getlist('fitting_quantities[]') - def_ids, def_qtys = request.form.getlist('defect_material_id[]'), request.form.getlist('defect_quantity[]') - if not cut_id or not prod_name or not qty_s: flash("Заполните основные поля.", "danger"); return redirect(url_for('sewing')) - cut_task = find_item_by_id(cut_id, 'cutting_tasks') - if not cut_task or cut_task.get('status') != 'pending': flash("Задание раскроя не найдено/не готово.", "danger"); return redirect(url_for('sewing')) - try: qty = int(to_decimal(qty_s).to_integral_value()); cut_qty = cut_task.get('cut_items_quantity', 0); assert qty > 0 and qty <= cut_qty - except: flash(f"Кол-во сшитых: целое > 0 и <= {cut_qty}.", "danger"); return redirect(url_for('sewing')) - fit_cons, def_log, mats_upd, fit_cost, ok = [], [], {}, Decimal('0'), True; ctime = get_current_time().isoformat() - if fit_ids and fit_qtys and len(fit_ids) == len(fit_qtys): - for i in range(len(fit_ids)): - fid, fq_s = fit_ids[i], fit_qtys[i] - if not fid or not fq_s: continue - fit_m = find_item_by_id(fid, 'materials'); - if not fit_m: flash(f"Фурнитура {fid} не найдена!", "danger"); ok=False; break - try: fq = int(to_decimal(fq_s).to_integral_value()); assert fq > 0 - except: flash(f"Некорр. кол-во фурнитуры '{fit_m['name']}'.", "danger"); ok=False; break - avail = to_decimal(fit_m.get('quantity','0')); needed = Decimal(fq); planned = mats_upd.get(fid, Decimal('0')) - if avail < planned + needed: flash(f"Недостаточно '{fit_m['name']}'. В наличии: {format_integer_py(avail)}, уже в плане: {format_integer_py(planned)}, нужно еще: {format_integer_py(needed)}.", "danger"); ok=False; break - mats_upd[fid] = planned + needed; price = to_decimal(fit_m.get('price_per_unit','0')); cost = price * needed; fit_cost += cost - fit_cons.append({'fitting_id': fid, 'fitting_name': fit_m['name'], 'quantity_used': int(needed), 'cost': str(cost)}) - if not ok: return redirect(url_for('sewing')) - if def_ids and def_qtys and len(def_ids) == len(def_qtys): - for i in range(len(def_ids)): - did, dq_s = def_ids[i], def_qtys[i] - if not did or not dq_s: continue - def_m = find_item_by_id(did, 'materials'); - if not def_m: flash(f"Материал брака {did} не найден.", "warning"); continue - try: - dq_d, fdq, isf = Decimal('0'), 0, def_m['type'] == 'fabric' - if isf: dq_d, fdq = to_decimal(dq_s), to_decimal(dq_s) - else: dq_d, fdq = Decimal(int(to_decimal(dq_s).to_integral_value())), int(to_decimal(dq_s).to_integral_value()) - assert dq_d > 0 - except: flash(f"Некорр. кол-во брака '{def_m['name']}'.", "warning"); continue - avail = to_decimal(def_m.get('quantity','0')); planned = mats_upd.get(did, Decimal('0')); eff_avail = avail - planned - if eff_avail < dq_d: flash(f"Недостаточно '{def_m['name']}' для брака ({dq_d}). Доступно: {format_currency_py(eff_avail) if isf else format_integer_py(eff_avail)}.", "danger"); ok=False; break - mats_upd[did] = planned + dq_d; price = to_decimal(def_m.get('price_per_unit','0')); dcost = price * dq_d - def_log.append({'log_id':uuid.uuid4().hex, 'material_id':did, 'material_name':def_m['name'], 'quantity':str(fdq) if isf else int(fdq), - 'unit':def_m['unit'], 'type':def_m['type'], 'stage':'sewing', 'reason':'Брак при пошиве', - 'cost':str(dcost), 'sewing_task_id':None, 'timestamp':ctime}) - if not ok: return redirect(url_for('sewing')) - mats = data.get('materials', []) - for mid, qd in mats_upd.items(): - upd = False - for i, m in enumerate(mats): - if m.get('id') == mid: - cur_q, isf = to_decimal(m.get('quantity','0')), m['type'] == 'fabric' - new_q = (cur_q - qd).quantize(Decimal('0.01') if isf else Decimal('1'), rounding=ROUND_HALF_UP) - if new_q < 0: new_q = Decimal('0') - mats[i]['quantity'], mats[i]['timestamp_last_updated'], upd = str(new_q), ctime, True; break - if not upd: flash(f"Критич. ошибка списания {mid}.", "danger"); return redirect(url_for('sewing')) - sal_s, sew_cost = to_decimal(config.get('salary_sewer_per_unit','0')), Decimal(qty) * to_decimal(config.get('salary_sewer_per_unit','0')) - sew_task = {'id': uuid.uuid4().hex, 'cutting_task_id': cut_id, 'product_name': prod_name, 'sewn_quantity': qty, - 'fabric_id': cut_task['fabric_id'], 'fabric_name': cut_task['fabric_name'], 'fittings_consumed': fit_cons, - 'defects_reported': [], 'status': 'pending_qc', 'timestamp_created': ctime, 'timestamp_completed': None, - 'qc_packed_quantity': 0, 'qc_defective_quantity': 0, 'fittings_cost': str(fit_cost), - 'sewing_salary_cost': str(sew_cost), 'cutting_salary_cost': cut_task.get('cutting_salary_cost', '0.00')} - for d in def_log: d['sewing_task_id'] = sew_task['id']; sew_task['defects_reported'].append(d) - cut_tasks = data.get('cutting_tasks', []); task_upd = False - for i, t in enumerate(cut_tasks): - if t.get('id') == cut_id and t['status'] == 'pending': - cut_tasks[i]['status'], cut_tasks[i]['timestamp_completed'], task_upd = 'completed', ctime, True - logging.info(f"Статус раскроя {cut_id} изменен на completed."); break - elif t.get('id') == cut_id: task_upd = True; logging.info(f"Раскрой {cut_id} уже был completed.") ; break - if not task_upd: logging.error(f"Не удалось обновить статус раскроя {cut_id}.") + cutting_task_id = request.form.get('cutting_task_id') + sewn_product_name = request.form.get('sewn_product_name', '').strip() + sewn_quantity_str = request.form.get('sewn_quantity') + + # Фурнитура + fitting_ids = request.form.getlist('fitting_ids[]') + fitting_quantities = request.form.getlist('fitting_quantities[]') + + # Брак + defect_material_ids = request.form.getlist('defect_material_id[]') + defect_quantities = request.form.getlist('defect_quantity[]') + + # Валидация основных полей + if not cutting_task_id or not sewn_product_name or not sewn_quantity_str: + flash("Необходимо выбрать задание на раскрой, указать название изделия и количество сшитых.", "danger") + return redirect(url_for('sewing')) + + # Находим задание на раскрой + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') + if not cutting_task or cutting_task.get('status') != 'pending': + flash("Выбранное задание на раскрой не найдено или уже не находится в статусе 'Ожидает пошива'.", "danger") + return redirect(url_for('sewing')) + + # Валидация количества сшитых + try: + sewn_quantity = int(to_decimal(sewn_quantity_str).to_integral_value()) + cut_quantity = cutting_task.get('cut_items_quantity', 0) # find_item_by_id вернул int + if sewn_quantity <= 0: raise ValueError("Кол-во > 0") + if sewn_quantity > cut_quantity: + flash(f"Количество сшитых ({sewn_quantity}) не может превышать количество раскроенных ({cut_quantity}).", "danger") + return redirect(url_for('sewing')) + except (InvalidOperation, ValueError): + flash("Некорректное количество сшитых изделий. Введите целое положительное число.", "danger") + return redirect(url_for('sewing')) + + # --- Обработка фурнитуры --- + fittings_consumed = [] + fittings_total_cost = Decimal('0') + materials_to_update = {} # Словарь {material_id: quantity_to_deduct} для атомарного списания + sewing_time = get_current_time().isoformat() + is_valid = True + + if fitting_ids and fitting_quantities and len(fitting_ids) == len(fitting_quantities): + for i in range(len(fitting_ids)): + fitting_id = fitting_ids[i] + quantity_str = fitting_quantities[i] + + if not fitting_id or not quantity_str: continue # Пропускаем пустые строки фурнитуры + + fitting_material = find_item_by_id(fitting_id, 'materials') + if not fitting_material or fitting_material.get('type') != 'fittings': + flash(f"Ошибка: Фурнитура с ID {fitting_id} не найдена или не является фурнитурой.", "danger") + is_valid = False; break + + try: + quantity_used = int(to_decimal(quantity_str).to_integral_value()) + if quantity_used <= 0: raise ValueError("Кол-во > 0") + except (InvalidOperation, ValueError): + flash(f"Некорректное количество для фурнитуры '{fitting_material['name']}'.", "danger") + is_valid = False; break + + # Проверка доступности с учетом уже запланированного списания + 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: + flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. " + f"В наличии: {format_integer_py(available_qty_int)}, " + f"уже запланировано списать: {format_integer_py(planned_deduction)}, " + f"требуется еще: {format_integer_py(quantity_used)}.", "danger") + is_valid = False; break + + # Добавляем в план списания + materials_to_update[fitting_id] = planned_deduction + Decimal(quantity_used) + + # Считаем стоимость фурнитуры + price = fitting_material.get('price_per_unit', Decimal('0.00')) + cost = price * Decimal(quantity_used) + fittings_total_cost += cost + + # Добавляем в список использованной фурнитуры для задачи пошива + fittings_consumed.append({ + 'fitting_id': fitting_id, + 'fitting_name': fitting_material['name'], + 'quantity_used': quantity_used, # Сохраняем int + 'cost': str(cost) # Сохраняем строку + }) + + if not is_valid: return redirect(url_for('sewing')) + + # --- Обработка брака --- + defects_reported = [] + if defect_material_ids and defect_quantities and len(defect_material_ids) == len(defect_quantities): + for i in range(len(defect_material_ids)): + material_id = defect_material_ids[i] + quantity_str = defect_quantities[i] + + if not material_id or not quantity_str: continue # Пропускаем пустые строки брака + + defect_material = find_item_by_id(material_id, 'materials') + if not defect_material: + flash(f"Предупреждение: Материал для брака с ID {material_id} не найден.", "warning") + continue # Пропускаем, но не останавливаем процесс + + # Валидация количества брака в зависимости от типа + material_type = defect_material.get('type') + quantity_deduct = Decimal('0') + quantity_log_value = 0 # Значение для записи в лог (int или Decimal) + is_fabric = material_type == 'fabric' + + try: + if is_fabric: + quantity_deduct = to_decimal(quantity_str) + if quantity_deduct <= 0: raise ValueError("Кол-во ткани > 0") + quantity_log_value = quantity_deduct # Decimal для лога + else: # fittings + quantity_int = int(to_decimal(quantity_str).to_integral_value()) + if quantity_int <= 0: raise ValueError("Кол-во фурн. > 0") + quantity_deduct = Decimal(quantity_int) + quantity_log_value = quantity_int # int для лога + except (InvalidOperation, ValueError): + flash(f"Некорректное количество брака для '{defect_material['name']}'.", "warning") + continue # Пропускаем эту запись брака + + # Проверка доступности с учетом уже запланированного + available_qty = defect_material.get('quantity', Decimal('0')) + planned_deduction = materials_to_update.get(material_id, Decimal('0')) + effective_available = available_qty - planned_deduction + + if effective_available < quantity_deduct: + available_str = format_currency_py(effective_available) if is_fabric else format_integer_py(effective_available) + deduct_str = format_currency_py(quantity_deduct) if is_fabric else format_integer_py(quantity_deduct) + flash(f"Недостаточно '{defect_material['name']}' для списания в брак ({deduct_str} {defect_material['unit']}). " + f"Доступно с учетом других списаний: {available_str} {defect_material['unit']}.", "danger") + is_valid = False; break + + # Добавляем в план списания + materials_to_update[material_id] = planned_deduction + quantity_deduct + + # Считаем стоимость брака + price = defect_material.get('price_per_unit', Decimal('0.00')) + defect_cost = price * quantity_deduct + + # Готовим запись для лога брака + defect_entry = { + 'log_id': uuid.uuid4().hex, + 'material_id': material_id, + 'material_name': defect_material['name'], + 'quantity': str(quantity_log_value) if is_fabric else int(quantity_log_value), # Строка Decimal или int + 'unit': defect_material['unit'], + 'type': material_type, + 'stage': 'sewing', # Этап, на котором обнаружен брак + 'reason': 'Брак при пошиве', # Можно сделать поле в форме + 'cost': str(defect_cost), # Сохраняем строку + 'sewing_task_id': None, # Будет установлен после создания задачи пошива + 'timestamp': sewing_time + } + defects_reported.append(defect_entry) + + if not is_valid: return redirect(url_for('sewing')) + + # --- Списание материалов --- + current_materials = data.get('materials', []) + for material_id, quantity_to_deduct in materials_to_update.items(): + material_updated = False + for i, mat in enumerate(current_materials): + if 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) # Округляем до целого + # Убедимся, что не ушли в минус + if new_qty < 0: new_qty = Decimal('0') + + current_materials[i]['quantity'] = str(new_qty) + current_materials[i]['timestamp_last_updated'] = sewing_time + material_updated = True + break + if not material_updated: + # Этого не должно произойти, если проверки выше сработали + flash(f"Критическая ошибка: Не удалось списать материал с ID {material_id}.", "danger") + return redirect(url_for('sewing')) + + # --- Расчет зарплаты швеи --- + salary_sewer_per_unit = to_decimal(config.get('salary_sewer_per_unit', '0.00')) + sewing_salary_cost = Decimal(sewn_quantity) * salary_sewer_per_unit + + # --- Создание задачи пошива --- + sewing_task = { + 'id': uuid.uuid4().hex, + 'cutting_task_id': cutting_task_id, + 'product_name': sewn_product_name, + 'sewn_quantity': sewn_quantity, + 'fabric_id': cutting_task['fabric_id'], # Для справки + 'fabric_name': cutting_task['fabric_name'], # Для справки + 'fittings_consumed': fittings_consumed, # Список использованной фурнитуры + 'defects_reported': [], # Сюда добавим записи из defects_reported + 'status': 'pending_qc', # Отправляем на ОТК + 'timestamp_created': sewing_time, + 'timestamp_completed': None, # Время завершения (после ОТК/упаковки) + 'qc_packed_quantity': 0, # Количество, прошедшее ОТК + 'qc_defective_quantity': 0, # Количество брака на этапе ОТК + 'fittings_cost': str(fittings_total_cost), # Общая стоимость фурнитуры + 'sewing_salary_cost': str(sewing_salary_cost), # ЗП швеи + # Переносим стоимость ЗП раскройщика из задачи раскроя + 'cutting_salary_cost': cutting_task.get('cutting_salary_cost', '0.00') + } + + # Привязываем ID задачи пошива к записям о браке и добавляем их в задачу + for defect in defects_reported: + defect['sewing_task_id'] = sewing_task['id'] + sewing_task['defects_reported'].append(defect) + + # --- Обновление статуса задачи раскроя --- + current_cutting_tasks = data.get('cutting_tasks', []) + cutting_task_updated = False + for i, task in enumerate(current_cutting_tasks): + if 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: + # Задача найдена, но статус уже не pending (маловероятно из-за проверки выше) + cutting_task_updated = True # Считаем, что обработали, раз нашли + logging.warning(f"Попытка обновить статус для уже обработанного задания раскроя {cutting_task_id}.") + break + + if not cutting_task_updated: + # Этого тоже не должно произойти + logging.error(f"Критическая ошибка: Не удалось найти и обновить статус задания на раскрой {cutting_task_id}.") + flash(f"Критическая ошибка при обновлении статуса задания раскроя {cutting_task_id}.", "danger") + # Решаем, откатывать ли транзакцию или продолжить с предупреждением + # Пока продолжим, но залогировали ошибку. + + # --- Сохранение всех изменений --- if 'sewing_tasks' not in data: data['sewing_tasks'] = [] - data['sewing_tasks'].append(sew_task) - if def_log: + data['sewing_tasks'].append(sewing_task) + + # Добавляем брак в общий лог брака, если он был + if defects_reported: if 'defect_log' not in data: data['defect_log'] = [] - data['defect_log'].extend(sew_task['defects_reported']) - data['materials'], data['cutting_tasks'] = mats, cut_tasks - save_data(data); flash(f"Пошив {qty} ед. '{prod_name}' зарегистрирован. Отправлено на ОТК.", "success") - if def_log: flash(f"Зарегистрирован брак: {len(def_log)} поз.", "warning") - upload_db_to_hf(DATA_FILE); return redirect(url_for('sewing')) - except Exception as e: logging.error(f"Ошибка пошива: {e}", exc_info=True); flash(f"Ошибка обработки пошива: {e}", "danger") - tasks_tmpl = [] - for tid in [t['id'] for t in pend_tasks]: - tdata = find_item_by_id(tid, 'cutting_tasks') - if tdata: tdata['fabric_used_str'] = format_currency_py(tdata.get('fabric_used', '0.00')); tasks_tmpl.append(tdata) - fits_dec = [] - for f in fittings: - fcp = find_item_by_id(f['id'], 'materials') - if fcp: fcp['quantity_str'] = format_integer_py(fcp.get('quantity', '0')); fits_dec.append(fcp) - all_mats_dec = [] - for m in all_mats: - mdat = find_item_by_id(m['id'], 'materials') - if mdat: - if mdat['type'] == 'fabric': mdat['quantity_str'] = format_currency_py(mdat.get('quantity', '0.00')) - else: mdat['quantity_str'] = format_integer_py(mdat.get('quantity', '0')) - all_mats_dec.append(mdat) + data['defect_log'].extend(sewing_task['defects_reported']) # Добавляем те же объекты + + data['materials'] = current_materials + data['cutting_tasks'] = current_cutting_tasks + + save_data(data) + flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' успешно зарегистрирован. Статус: Ожидает ОТК.", "success") + if defects_reported: + flash(f"Зарегистрировано {len(defects_reported)} позиций брака.", "warning") + + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('sewing')) + + except Exception as e: + logging.error(f"Ошибка при регистрации пошива: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при регистрации пошива: {e}", "danger") + return redirect(url_for('sewing')) + + # GET запрос: отображаем страницу + # Готовим данные для шаблона + tasks_for_template = [] + for task_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) + + 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) + + 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) + html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Пошив изделий").replace('__CONTENT__', SEWING_CONTENT).replace('__SCRIPTS__', SEWING_SCRIPTS) - return render_template_string(html, cutting_tasks=tasks_tmpl, fittings=fits_dec, all_materials=all_mats_dec) + return render_template_string(html, cutting_tasks=tasks_for_template, fittings=fittings_for_template, all_materials=all_materials_for_template) # 4. Маршрут "ОТК и Упаковка" @app.route('/qc_packing', methods=['GET', 'POST']) def qc_packing(): data = load_data() - pend_tasks = [t for t in data.get('sewing_tasks', []) if t.get('status') == 'pending_qc'] + # Находим задания пошива, ожидающие ОТК + pending_qc_tasks = [t for t in data.get('sewing_tasks', []) if t.get('status') == 'pending_qc'] config = data.get('config', {}) + if request.method == 'POST': try: - sew_id, qty_p_s, qty_d_s, def_r = request.form.get('sewing_task_id'), request.form.get('quantity_packed'), request.form.get('quantity_defective', '0'), request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() - if not sew_id: flash("Выберите задание.", "danger"); return redirect(url_for('qc_packing')) - sew_task = find_item_by_id(sew_id, 'sewing_tasks') - if not sew_task or sew_task.get('status') != 'pending_qc': flash("Задание не найдено/обработано.", "danger"); return redirect(url_for('qc_packing')) + sewing_task_id = request.form.get('sewing_task_id') + quantity_packed_str = request.form.get('quantity_packed') + quantity_defective_str = request.form.get('quantity_defective', '0') # По умолчанию 0 + defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() + + # Валидация выбора задания + if not sewing_task_id: + flash("Необходимо выбрать задание на пошив.", "danger") + return redirect(url_for('qc_packing')) + + # Находим задание пошива + sewing_task = find_item_by_id(sewing_task_id, 'sewing_tasks') + if not sewing_task or sewing_task.get('status') != 'pending_qc': + flash("Выбранное задание на пошив не найдено или уже не ожидает ОТК.", "danger") + return redirect(url_for('qc_packing')) + + # Валидация количества try: - qty_p, qty_d = (int(to_decimal(qty_p_s).to_integral_value()) if qty_p_s else 0), (int(to_decimal(qty_d_s).to_integral_value()) if qty_d_s else 0) - assert qty_p >= 0 and qty_d >= 0; total_proc = qty_p + qty_d; sew_qty = sew_task.get('sewn_quantity', 0) - proc_before = sew_task.get('qc_packed_quantity', 0) + sew_task.get('qc_defective_quantity', 0); remain = sew_qty - proc_before - assert total_proc > 0 and total_proc <= remain - except: flash(f"Некорр. кол-во (0 < сумма <= {remain}).", "danger"); return redirect(url_for('qc_packing')) - ctime, packed_entry, cost_batch, price_batch = get_current_time().isoformat(), None, Decimal('0'), Decimal('0') - if qty_p > 0: - cut_task = find_item_by_id(sew_task.get('cutting_task_id'), 'cutting_tasks') or {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} - sal_p, marg = to_decimal(config.get('salary_packer_per_unit','0')), to_decimal(config.get('margin_per_item','0')) - fab_cost_t, cut_sal_t, fit_cost_t, sew_sal_t = cut_task.get('material_cost',Decimal('0')), cut_task.get('cutting_salary_cost',Decimal('0')), sew_task.get('fittings_cost',Decimal('0')), sew_task.get('sewing_salary_cost',Decimal('0')) - cut_n, sew_n = cut_task.get('cut_items_quantity',1) or 1, sew_task.get('sewn_quantity',1) or 1 - fab_cpi, fit_cpi, cut_spi, sew_spi, pack_spi = fab_cost_t/Decimal(cut_n), fit_cost_t/Decimal(sew_n), cut_sal_t/Decimal(cut_n), sew_sal_t/Decimal(sew_n), sal_p - mat_cpi, sal_cpi, tot_cpi, fin_ppi = fab_cpi+fit_cpi, cut_spi+sew_spi+pack_spi, (fab_cpi+fit_cpi)+(cut_spi+sew_spi+pack_spi), (fab_cpi+fit_cpi)+(cut_spi+sew_spi+pack_spi)+marg - mat_cost_b, sal_cost_b, cost_batch, marg_b, price_batch = mat_cpi*Decimal(qty_p), sal_cpi*Decimal(qty_p), tot_cpi*Decimal(qty_p), marg*Decimal(qty_p), fin_ppi*Decimal(qty_p) - packed_entry = {'id':uuid.uuid4().hex, 'sewing_task_id':sew_id, 'product_name':sew_task['product_name'], 'quantity':qty_p, 'timestamp_packed':ctime, - 'packed_material_cost':str(mat_cost_b), 'packed_salary_cost':str(sal_cost_b), 'packed_total_cost':str(cost_batch), - 'packed_margin':str(marg_b), 'packed_final_price':str(price_batch), 'status':'packed_ready_to_ship', 'shipment_details':None} - if 'qc_packing_items' not in data: data['qc_packing_items'] = [] - data['qc_packing_items'].append(packed_entry) - if qty_d > 0: - cut_task = find_item_by_id(sew_task.get('cutting_task_id'), 'cutting_tasks') or {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} - fab_cost_t, cut_sal_t, fit_cost_t, sew_sal_t = cut_task.get('material_cost',Decimal('0')), cut_task.get('cutting_salary_cost',Decimal('0')), sew_task.get('fittings_cost',Decimal('0')), sew_task.get('sewing_salary_cost',Decimal('0')) - cut_n, sew_n = cut_task.get('cut_items_quantity',1) or 1, sew_task.get('sewn_quantity',1) or 1 - fab_cpi, fit_cpi, cut_spi, sew_spi = fab_cost_t/Decimal(cut_n), fit_cost_t/Decimal(sew_n), cut_sal_t/Decimal(cut_n), sew_sal_t/Decimal(sew_n) - mat_cpi, sal_cpi_def = fab_cpi+fit_cpi, cut_spi+sew_spi - cost_def_i = mat_cpi + sal_cpi_def; tot_def_cost = cost_def_i * Decimal(qty_d) - def_log_e = {'log_id':uuid.uuid4().hex, 'material_id':None, 'material_name':sew_task['product_name']+" (готовое изделие)", - 'quantity':qty_d, 'unit':'шт', 'type':'finished_product', 'stage':'qc_packing', 'reason':def_r if def_r else 'Брак при ОТК/упаковке', - 'cost':str(tot_def_cost), 'sewing_task_id':sew_id, 'timestamp':ctime} - if 'defect_log' not in data: data['defect_log'] = [] - data['defect_log'].append(def_log_e); logging.info(f"Брак ОТК: {qty_d} ед. '{sew_task['product_name']}' (Стоимость: {format_currency_py(tot_def_cost)})") - task_upd = False; sew_tasks = data.get('sewing_tasks', []) - for i, t in enumerate(sew_tasks): - if t.get('id') == sew_id: - t['qc_packed_quantity'] = int(t.get('qc_packed_quantity', 0)) + qty_p - t['qc_defective_quantity'] = int(t.get('qc_defective_quantity', 0)) + qty_d - if t['qc_packed_quantity'] + t['qc_defective_quantity'] >= int(t.get('sewn_quantity', 0)): - t['status'], t['timestamp_completed'] = 'completed', ctime; logging.info(f"Пошив {sew_id} завершен.") - else: t['status'] = 'pending_qc'; logging.info(f"Пошив {sew_id} обработан частично.") - task_upd = True; break - if not task_upd: logging.error(f"Не удалось обновить пошив {sew_id}.") - data['sewing_tasks'] = sew_tasks + quantity_packed = int(to_decimal(quantity_packed_str).to_integral_value()) if quantity_packed_str else 0 + quantity_defective = int(to_decimal(quantity_defective_str).to_integral_value()) if quantity_defective_str else 0 + + if quantity_packed < 0 or quantity_defective < 0: + raise ValueError("Количество не может быть отрицательным") + + total_processed_now = quantity_packed + quantity_defective + if total_processed_now <= 0: + flash("Необходимо указать количество упакованных или бракованных изделий (сумма должна быть > 0).", "warning") + return redirect(url_for('qc_packing')) + + # Рассчитываем, сколько осталось обработать по этому заданию + total_sewn = sewing_task.get('sewn_quantity', 0) + already_packed = sewing_task.get('qc_packed_quantity', 0) + already_defective = sewing_task.get('qc_defective_quantity', 0) + remaining_to_process = total_sewn - (already_packed + already_defective) + + if total_processed_now > remaining_to_process: + flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed_now}, " + f"что превышает остаток изделий для обработки ({remaining_to_process}).", "danger") + return redirect(url_for('qc_packing')) + + except (InvalidOperation, ValueError) as e: + flash(f"Некорректное количество упакованных или бракованных изделий: {e}", "danger") + return redirect(url_for('qc_packing')) + + qc_time = get_current_time().isoformat() + new_packed_item_entry = None # Запись для qc_packing_items + new_defect_log_entry = None # Запись для defect_log + + # --- Обработка упакованных (прошедших ОТК) --- + if quantity_packed > 0: + # Получаем связанные данные для расчета себестоимости + cutting_task_id = sewing_task.get('cutting_task_id') + # Используем find_item_by_id, который вернет данные с Decimal + 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) + fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) + cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) + fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) + sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) + + # Получаем количество из задач для расчета на единицу + cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 # Избегаем деления на ноль + sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 # Избегаем деления на ноль + + # Расчет себестоимости на 1 изделие + fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) + fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + + cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) + sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) + packing_salary_per_item = to_decimal(config.get('salary_packer_per_unit', '0.00')) + salary_cost_per_item = cutting_salary_per_item + sewing_salary_per_item + packing_salary_per_item + + total_cost_per_item = material_cost_per_item + salary_cost_per_item + + # Расчет цены продажи на 1 изделие + margin_per_item = to_decimal(config.get('margin_per_item', '0.00')) + final_price_per_item = total_cost_per_item + margin_per_item + + # Расчет общих сумм для текущей пачки упакованных + packed_batch_material_cost = material_cost_per_item * Decimal(quantity_packed) + packed_batch_salary_cost = salary_cost_per_item * Decimal(quantity_packed) + packed_batch_total_cost = total_cost_per_item * Decimal(quantity_packed) + packed_batch_margin = margin_per_item * Decimal(quantity_packed) + packed_batch_final_price = final_price_per_item * Decimal(quantity_packed) + + # Создаем запись об упакованном товаре + new_packed_item_entry = { + 'id': uuid.uuid4().hex, + 'sewing_task_id': sewing_task_id, + 'product_name': sewing_task['product_name'], + 'quantity': quantity_packed, # int + 'timestamp_packed': qc_time, + 'packed_material_cost': str(packed_batch_material_cost), + 'packed_salary_cost': str(packed_batch_salary_cost), + 'packed_total_cost': str(packed_batch_total_cost), # Себестоимость партии + 'packed_margin': str(packed_batch_margin), # Маржа партии + 'packed_final_price': str(packed_batch_final_price), # Цена продажи партии + 'status': 'packed_ready_to_ship', # Готово к отправке + 'shipment_details': None # Детали отправки будут добавлены позже + } + + if 'qc_packing_items' not in data: data['qc_packing_items'] = [] + data['qc_packing_items'].append(new_packed_item_entry) + + # --- Обработка брака на этапе ОТК --- + if quantity_defective > 0: + # Рассчитываем себестоимость бракованного изделия (без ЗП упаковщика и без маржи) + cutting_task_id = sewing_task.get('cutting_task_id') + cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') + if not cutting_task: + logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости брака ОТК для {sewing_task_id}.") + cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} + + fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) + cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) + fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) + sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) + cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 + sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 + + fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) + fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) + material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item + + cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) + sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) + # ЗП упаковщика НЕ включаем в себестоимость брака ОТК + salary_cost_per_defective_item = cutting_salary_per_item + sewing_salary_per_item + + # Себестоимость 1 бракованного изделия + cost_per_defective_item = material_cost_per_item + salary_cost_per_defective_item + # Общая стоимость списания брака + total_defect_cost = cost_per_defective_item * Decimal(quantity_defective) + + # Создаем запись для лога брака + new_defect_log_entry = { + 'log_id': uuid.uuid4().hex, + 'material_id': None, # Брак не конкретного материала, а готового изделия + 'material_name': f"{sewing_task['product_name']} (готовое изделие)", + 'quantity': quantity_defective, # int + 'unit': 'шт', + 'type': 'finished_product', # Тип брака - готовое изделие + 'stage': 'qc_packing', # Этап обнаружения + 'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке', + 'cost': str(total_defect_cost), # Общая стоимость списания + 'sewing_task_id': sewing_task_id, # Ссылка на задачу пошива + 'timestamp': qc_time + } + + if 'defect_log' not in data: data['defect_log'] = [] + data['defect_log'].append(new_defect_log_entry) + logging.info(f"Зарегистрирован брак ОТК: {quantity_defective} ед. '{sewing_task['product_name']}' (Общая стоимость: {format_currency_py(total_defect_cost)})") + + + # --- Обновление задачи пошива --- + sewing_task_updated = False + current_sewing_tasks = data.get('sewing_tasks', []) + for i, task in enumerate(current_sewing_tasks): + if task.get('id') == sewing_task_id: + # Увеличиваем счетчики обработанных + current_sewing_tasks[i]['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) + quantity_packed + current_sewing_tasks[i]['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) + quantity_defective + + # Проверяем, завершена ли обработка всего задания + total_processed_for_task = current_sewing_tasks[i]['qc_packed_quantity'] + current_sewing_tasks[i]['qc_defective_quantity'] + if total_processed_for_task >= int(task.get('sewn_quantity', 0)): + # Если все обработано, меняем статус и ставим время завершения + current_sewing_tasks[i]['status'] = 'completed' + current_sewing_tasks[i]['timestamp_completed'] = qc_time + logging.info(f"Задание на пошив {sewing_task_id} полностью обработано и завершено.") + else: + # Если обработано частично, статус остается pending_qc + current_sewing_tasks[i]['status'] = 'pending_qc' # Явно оставляем статус + logging.info(f"Задание на пошив {sewing_task_id} обработано частично. Осталось: {remaining_to_process - total_processed_now}") + + sewing_task_updated = True + break + + if not sewing_task_updated: + logging.error(f"Критическая ошибка: Не удалось найти и обновить задание на пошив {sewing_task_id}.") + # Возможно, стоит откатить изменения или выдать более серьезное предупреждение + flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger") + + + # --- Сохранение данных --- + data['sewing_tasks'] = current_sewing_tasks save_data(data) - flash_msg = f"ОТК/Упаковка для '{sew_task['product_name']}': упаковано {qty_p}, брак {qty_d}. " - if packed_entry: flash_msg += f"Статус упак.: Готово к отправке." - flash(flash_msg, "success"); upload_db_to_hf(DATA_FILE) + + flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. " + if new_packed_item_entry: + flash_message += f"Статус упакованных: Готово к отправке." + elif new_defect_log_entry: + flash_message += f"Брак зарегистрирован." + + flash(flash_message, "success") + upload_db_to_hf(DATA_FILE) # Бэкап return redirect(url_for('qc_packing')) - except Exception as e: logging.error(f"Ошибка ОТК: {e}", exc_info=True); flash(f"Ошибка обработки ОТК: {e}", "danger") - tasks_tmpl = [] - for tid in [t['id'] for t in pend_tasks]: - tdata = find_item_by_id(tid, 'sewing_tasks') - if tdata: - remain = tdata['sewn_quantity'] - (tdata['qc_packed_quantity'] + tdata['qc_defective_quantity']) - if remain > 0: tdata['remaining_quantity'] = remain; tasks_tmpl.append(tdata) + + except Exception as e: + logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True) + flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger") + return redirect(url_for('qc_packing')) + + # GET запрос: отображаем страницу + # Готовим список задач для шаблона, рассчитывая остаток + tasks_for_template = [] + for task_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) + html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS) - return render_template_string(html, sewing_tasks=tasks_tmpl) + return render_template_string(html, sewing_tasks=tasks_for_template) # 5. Маршрут "База клиентов" @app.route('/clients', methods=['GET', 'POST']) def clients_panel(): if request.method == 'POST': - name, phone, addr = request.form.get('client_name','').strip(), request.form.get('client_phone','').strip(), request.form.get('client_address','').strip() - if not name or not phone: flash("Имя/Назв. и телефон обязательны.", "danger"); return redirect(url_for('clients_panel')) - clients = load_client_data(); norm_phone = ''.join(filter(str.isdigit, phone)) - if any(''.join(filter(str.isdigit, c.get('phone',''))) == norm_phone for c in clients): - flash(f"Клиент с похожим номером ({phone}) уже есть.", "warning"); return redirect(url_for('clients_panel')) - new_client = {'id':uuid.uuid4().hex, 'name':name, 'phone':phone, 'address':addr if addr else None, 'history':[]} - clients.append(new_client); save_client_data(clients); flash(f"Клиент '{name}' добавлен.", "success"); upload_db_to_hf(CLIENT_DATA_FILE) + # Добавление нового клиента + name = request.form.get('client_name','').strip() + phone = request.form.get('client_phone','').strip() + address = request.form.get('client_address','').strip() + + if not name or not phone: + flash("Имя/Название организации и номер телефона обязательны.", "danger") + return redirect(url_for('clients_panel')) + + clients = load_client_data() + # Проверка на дубликат по номеру телефона (очищенному от нецифровых символов) + normalized_phone = ''.join(filter(str.isdigit, phone)) + if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone for c in clients): + flash(f"Клиент с похожим номером телефона ({phone}) уже существует в базе.", "warning") + return redirect(url_for('clients_panel')) + + new_client = { + 'id': uuid.uuid4().hex, + 'name': name, + 'phone': phone, + 'address': address if address else None, # Сохраняем None, если адрес пуст + 'history': [] # Инициализируем пустую историю + } + clients.append(new_client) + save_client_data(clients) + flash(f"Клиент '{name}' успешно добавлен.", "success") + upload_db_to_hf(CLIENT_DATA_FILE) # Бэкап данных клиентов return redirect(url_for('clients_panel')) - clients_data = load_client_data(); clients_data.sort(key=lambda x: x.get('name','').lower()) + + # GET запрос: отображение списка клиентов + clients_data = load_client_data() + clients_data.sort(key=lambda x: x.get('name','').lower()) # Сортировка по имени + + # Подготовка данных для шаблона: обработка истории for client in clients_data: - if 'history' in client: + # --- НАЧАЛО ИСПРАВЛЕНИЯ (гарантируем, что 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) - for record in client['history']: record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) + # Добавляем 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) # 6. Маршрут "Админ-панель" @app.route('/admin') def admin_panel(): - data = load_data(); clients_data = load_client_data(); config = data.get('config', {}) - mats = [find_item_by_id(m['id'], 'materials') for m in data.get('materials',[]) if find_item_by_id(m['id'], 'materials')] - cuts = [find_item_by_id(t['id'], 'cutting_tasks') for t in data.get('cutting_tasks',[]) if find_item_by_id(t['id'], 'cutting_tasks')] - sews = [find_item_by_id(t['id'], 'sewing_tasks') for t in data.get('sewing_tasks',[]) if find_item_by_id(t['id'], 'sewing_tasks')] - packs = [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')] - defs = [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')] - exps = [find_item_by_id(e['id'], 'expenses') for e in data.get('expenses',[]) if find_item_by_id(e['id'], 'expenses')] - cats = data.get('categories', []) - ready = [i for i in packs if i.get('status') == 'packed_ready_to_ship'] - mat_c, cut_p, qc_p, pack_c = len(mats), len([t for t in cuts if t.get('status')=='pending']), len([t for t in sews if t.get('status')=='pending_qc']), sum(i.get('quantity',0) for i in packs) - def_f, def_t, def_p, def_cost = sum(d['quantity_raw'] for d in defs if d.get('type')=='fabric'), sum(d['quantity_raw'] for d in defs if d.get('type')=='fittings'), sum(d['quantity_raw'] for d in defs if d.get('type')=='finished_product'), sum(d.get('cost_dec',Decimal('0')) for d in defs) - conf_d = {k: to_decimal(v) for k, v in config.items()} + data = load_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')] + categories = data.get('categories', []) + + # --- Расчет сводных данных --- + # Готово к отправке + items_ready_to_ship = [item for item in all_packed_items if item.get('status') == 'packed_ready_to_ship'] + items_ready_ship_count = len(items_ready_to_ship) + items_ready_ship_qty = sum(item.get('quantity', 0) for item in items_ready_to_ship) # Используем поле quantity (int) + + # Счетчик материалов + materials_count = len(all_materials) + + # Ожидают пошива (задания раскроя) + pending_cutting_count = len([task for task in all_cutting_tasks if task.get('status') == 'pending']) + + # Ожидают ОТК (задания пошива) + pending_qc_tasks = [task for task in all_sewing_tasks if task.get('status') == 'pending_qc'] + pending_qc_count = len(pending_qc_tasks) + pending_qc_quantity = sum(task.get('sewn_quantity', 0) for task in pending_qc_tasks) # Сумма шт. в этих заданиях + + # Упаковано всего (по всем статусам) + total_packed_count = sum(item.get('quantity', 0) for item in all_packed_items) + + # Брак (используем поля _raw из find_item_by_id('defect_log', ...)) + total_defect_fabric_m = sum(d.get('quantity_raw', Decimal('0')) for d in all_defect_log if d.get('type') == 'fabric') + total_defect_fittings_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'fittings') + total_defect_finished_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'finished_product') + total_defect_cost = sum(d.get('cost_dec', Decimal('0')) for d in all_defect_log) # Используем поле _dec + + # Конфигурация (преобразуем в Decimal для удобства) + config_decimal = {k: to_decimal(v) for k, v in config.items()} + + # Передача данных в шаблон html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', ADMIN_SCRIPTS) - return render_template_string(html, materials=mats, cutting_tasks=cuts, sewing_tasks=sews, packed_items=packs, items_ready_to_ship=ready, - clients=sorted(clients_data, key=lambda x: x.get('name','').lower()), defect_log=defs, expenses=exps, categories=cats, config=conf_d, - materials_count=mat_c, pending_cutting_count=cut_p, pending_qc_count=qc_p, total_packed_count=pack_c, - items_ready_ship_count=len(ready), items_ready_ship_qty=sum(i.get('quantity',0) for i in ready), - total_defect_fabric_m=format_currency_py(def_f), total_defect_fittings_pcs=format_integer_py(def_t), - total_defect_finished_pcs=format_integer_py(def_p), total_defect_cost=format_currency_py(def_cost)) + return render_template_string(html, + materials=all_materials, # Уже обработаны find_item_by_id + cutting_tasks=all_cutting_tasks, + sewing_tasks=all_sewing_tasks, + packed_items=all_packed_items, + items_ready_to_ship=items_ready_to_ship, + clients=sorted(clients_data, key=lambda x: x.get('name','').lower()), # Сортируем клиентов здесь + defect_log=all_defect_log, + expenses=all_expenses, + categories=categories, + config=config_decimal, # Передаем с Decimal + # Сводные данные + materials_count=materials_count, + pending_cutting_count=pending_cutting_count, + pending_qc_count=pending_qc_count, + pending_qc_quantity=pending_qc_quantity, # Добавлено кол-во в ожидании ОТК + total_packed_count=total_packed_count, + items_ready_ship_count=items_ready_ship_count, + items_ready_ship_qty=items_ready_ship_qty, + total_defect_fabric_m=format_currency_py(total_defect_fabric_m), # Форматируем для вывода + total_defect_fittings_pcs=format_integer_py(total_defect_fittings_pcs), + total_defect_finished_pcs=format_integer_py(total_defect_finished_pcs), + total_defect_cost=format_currency_py(total_defect_cost) + ) # 7. Маршрут для выполнения отправки @app.route('/dispatch_item', methods=['POST']) def dispatch_item(): - item_id, dest_type, client_id = request.form.get('item_id'), request.form.get('destination_type'), request.form.get('client_id') - if not item_id or not dest_type: flash("Ошибка: Не указан ID товара или тип назначения.", "danger"); return redirect(url_for('admin_panel') + '#dispatch-content') - data = load_data(); clients = load_client_data() - item_found, client_found, client_name, item_ref, item_idx, client_data_ch, client_idx_save = False, False, None, None, -1, False, -1 - pack_items = data.get('qc_packing_items', []) - for i, item in enumerate(pack_items): - if item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': item_ref, item_idx, item_found = item, i, True; break - if not item_found: flash(f"Ошибка: Товар ID {item_id}, готовый к отправке, не найден.", "danger"); return redirect(url_for('admin_panel') + '#dispatch-content') - ctime_iso = get_current_time().isoformat(); ship_details = {'type': dest_type, 'timestamp': ctime_iso} - if dest_type == 'client': - if not client_id: flash("Ошибка: Не выбран клиент.", "danger"); return redirect(url_for('admin_panel') + '#dispatch-content') - client_obj_upd = None + item_id = request.form.get('item_id') + destination_type = request.form.get('destination_type') + client_id = request.form.get('client_id') # Может быть None, если не выбран клиент + + if not item_id or not destination_type: + flash("Ошибка: Не указан ID товара или тип назначения.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') # Возвращаемся на вкладку отправки + + data = load_data() + clients = load_client_data() # Загружаем данные клиентов + + packed_item_ref = None + packed_item_index = -1 + item_found = False + + # Ищем нужный упакованный товар в основном файле данных + 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': + packed_item_ref = item # Сохраняем ссылку на объект для изменения + packed_item_index = i + item_found = True + break + + if not item_found: + flash(f"Ошибка: Товар с ID {item_id}, готовый к отправке, не найден.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + # Подготовка деталей отправки + dispatch_time_iso = get_current_time().isoformat() + shipment_details = { + 'type': destination_type, + 'timestamp': dispatch_time_iso + } + client_data_changed = False # Флаг, указывающий, нужно ли сохранять файл клиентов + + # Обработка разных типов назначения + if destination_type == 'client': + if not client_id: + flash("Ошибка: Не выбран клиент для отправки.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + # Ищем клиента в загруженных данных + client_object_to_update = None + client_index_to_save = -1 # Индекс для сохранения обновленного клиента + client_found = False for i, cl in enumerate(clients): - if cl.get('id') == client_id: client_found, client_name, client_obj_upd, client_idx_save = True, cl.get('name'), cl, i; break - if not client_found: flash(f"Ошибка: Клиент ID {client_id} не найден.", "danger"); return redirect(url_for('admin_panel') + '#dispatch-content') - item_ref['status'], ship_details['client_id'], ship_details['client_name'], item_ref['shipment_details'] = 'shipped_client', client_id, client_name, ship_details - logging.info(f"Товар {item_id} помечен shipped_client для {client_name} ({client_id}).") - hist_entry = {'shipment_id': uuid.uuid4().hex, 'timestamp': ctime_iso, 'items': [{'product_name': item_ref['product_name'], 'quantity': item_ref['quantity']}], 'packed_item_id': item_id} - if 'history' not in client_obj_upd or not isinstance(client_obj_upd.get('history'), list): client_obj_upd['history'] = []; logging.warning(f"Инициализирован history для клиента {client_id}") - client_obj_upd['history'].append(hist_entry); logging.info(f"Запись добавлена в историю {client_id}. Длина: {len(client_obj_upd['history'])}"); client_data_ch = True - elif dest_type == 'dor_doi_point': - item_ref['status'], ship_details['destination'], item_ref['shipment_details'] = 'shipped_dor_doi', 'Торговая точка Дордой', ship_details - logging.info(f"Товар {item_id} помечен shipped_dor_doi.") - else: flash("Ошибка: Неверный тип назначения.", "danger"); return redirect(url_for('admin_panel') + '#dispatch-content') - data['qc_packing_items'] = pack_items; save_data(data); upload_db_to_hf(DATA_FILE); logging.info(f"Основные данные сохранены после отправки {item_id}.") - if client_data_ch: save_client_data(clients); upload_db_to_hf(CLIENT_DATA_FILE); logging.info(f"Данные клиента {client_id} сохранены.") - else: logging.info(f"Данные клиентов не изменялись при отправке {item_id}.") - dest_text = f"клиенту '{client_name}'" if dest_type == 'client' else "на Торговую точку Дордой" - flash(f"Товар '{item_ref['product_name']}' ({item_ref['quantity']} шт.) успешно отправлен {dest_text}.", "success") + 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 + + if not client_found: + flash(f"Ошибка: Клиент с ID {client_id} не найден в базе.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + # Обновляем статус товара и добавляем детали отправки + packed_item_ref['status'] = 'shipped_client' + shipment_details['client_id'] = client_id + shipment_details['client_name'] = client_name # Сохраняем имя для удобства + packed_item_ref['shipment_details'] = shipment_details + + # Добавляем запись в историю клиента + history_entry = { + 'shipment_id': uuid.uuid4().hex, # Уникальный ID самой отправки + 'timestamp': dispatch_time_iso, + 'items': [ # Список товаров в этой отправке (��ока только один) + { + 'product_name': packed_item_ref['product_name'], + 'quantity': packed_item_ref['quantity'] # Используем int из данных + } + ], + 'packed_item_id': item_id # Ссылка на 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} при добавлении записи.") + + 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}). Запись добавлена в историю клиента.") + destination_display_text = f"клиенту '{client_name}'" + + + elif destination_type == 'dor_doi_point': + # Обновляем статус товара и добавляем детали отправки + packed_item_ref['status'] = 'shipped_dor_doi' + shipment_details['destination'] = 'Торговая точка Дордой' # Пример названия + packed_item_ref['shipment_details'] = shipment_details + logging.info(f"Товар {item_id} ({packed_item_ref['product_name']}) помечен как 'shipped_dor_doi'.") + destination_display_text = "на Торговую точку Дордой" + + else: + flash("Ошибка: Неверный тип назначения.", "danger") + return redirect(url_for('admin_panel') + '#dispatch-content') + + # Сохраняем изменения в основном файле данных + # data['qc_packing_items'] уже содержит измененный packed_item_ref + save_data(data) + upload_db_to_hf(DATA_FILE) # Бэкап основного файла + logging.info(f"Основные данные сохранены после отправки товара {item_id}.") + + # Если данные клиента менялись (добавлялась история), сохраняем файл клиентов + if client_data_changed: + save_client_data(clients) + upload_db_to_hf(CLIENT_DATA_FILE) # Бэкап файла клиентов + logging.info(f"Данные клиента {client_id} сохранены после добавления истории отправки.") + else: + logging.info(f"Данные клиентов не изменялись при отправке товара {item_id} (отправка не клиенту).") + + + flash(f"Товар '{packed_item_ref['product_name']}' ({packed_item_ref['quantity']} шт.) успешно отправлен {destination_display_text}.", "success") return redirect(url_for('admin_panel') + '#dispatch-content') # --- Остальные маршруты админ-панели --- @app.route('/admin/config/update', methods=['POST']) def update_config(): - data = load_data(); config = data.get('config', {}) + data = load_data() + config = data.get('config', {}) # Получаем текущую конфигурацию или пустой словарь try: + # Получаем значения из формы, преобразуем в Decimal, затем в строку config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter'))) config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer'))) config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer'))) config['margin_per_item'] = str(to_decimal(request.form.get('margin'))) - data['config'] = config; save_data(data); flash("Настройки сохранены.", "success"); upload_db_to_hf(DATA_FILE) - except Exception as e: logging.error(f"Ошибка обновления config: {e}", exc_info=True); flash(f"Ошибка: {e}", "danger") + + data['config'] = config # Обновляем конфиг в основных данных + save_data(data) + flash("Настройки зарплат и маржи успешно сохранены.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + except InvalidOperation: + flash("Ошибка: Введено некорректное числовое значение.", "danger") + except Exception as e: + logging.error(f"Ошибка при обновлении конфигурации: {e}", exc_info=True) + flash(f"Произошла оши��ка при сохранении настроек: {e}", "danger") + # Возвращаемся на админ-панель (вкладка настроек не указана, будет по умолчанию) return redirect(url_for('admin_panel')) @app.route('/admin/expense/add', methods=['POST']) def add_expense(): - data = load_data(); desc, amount_s = request.form.get('expense_description','').strip(), request.form.get('expense_amount') - if not desc or not amount_s: flash("Заполните описание и сумму.", "warning"); return redirect(url_for('admin_panel')) - try: amount = to_decimal(amount_s); assert amount > 0 - except: flash("Сумма > 0.", "warning"); return redirect(url_for('admin_panel')) - if 'expenses' not in data: data['expenses'] = [] - data['expenses'].append({'id':uuid.uuid4().hex, 'description':desc, 'amount':str(amount), 'timestamp':get_current_time().isoformat()}) - save_data(data); flash(f"Расход '{desc}' ({format_currency_py(amount)}) добавлен.", "success"); upload_db_to_hf(DATA_FILE) - return redirect(url_for('admin_panel')) + data = load_data() + description = request.form.get('expense_description','').strip() + amount_str = request.form.get('expense_amount') + + if not description or not amount_str: + flash("Необходимо заполнить описание и сумму расхода.", "warning") + return redirect(url_for('admin_panel')) # Возвращаемся на админку (вкладка расходов) + + try: + amount = to_decimal(amount_str) + if amount <= 0: raise ValueError("Сумма должна быть > 0") + except (InvalidOperation, ValueError): + flash("Некорректное значение суммы расхода. Введите положительное число.", "warning") + return redirect(url_for('admin_panel')) + + if 'expenses' not in data or not isinstance(data['expenses'], list): + data['expenses'] = [] # Инициализируем, если отсутствует или не список + + new_expense = { + 'id': uuid.uuid4().hex, + 'description': description, + 'amount': str(amount), # Сохраняем как строку + 'timestamp': get_current_time().isoformat() + } + data['expenses'].append(new_expense) + save_data(data) + flash(f"Расход '{description}' на сумму {format_currency_py(amount)} сом успешно добавлен.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + return redirect(url_for('admin_panel') + '#expenses-report-content') # Переход на вкладку с расходами @app.route('/admin/category/add', methods=['POST']) def add_category(): - data = load_data(); cats = data.get('categories', []); new_cat = request.form.get('new_category_name','').strip() - if new_cat and new_cat.lower() not in [c.lower() for c in cats]: - cats.append(new_cat); data['categories'] = sorted(list(set(cats)), key=str.lower) - save_data(data); flash(f"Категория '{new_cat}' добавлена.", "success"); upload_db_to_hf(DATA_FILE) - elif not new_cat: flash("Название не может быть пустым.", "warning") - else: flash(f"Категория '{new_cat}' уже есть.", "warning") + data = load_data() + categories = data.get('categories', []) + new_category_name = request.form.get('new_category_name','').strip() + + if not new_category_name: + flash("Название категории не может быть пустым.", "warning") + return redirect(url_for('admin_panel')) # Возврат на админку + + # Проверка на существование (без учета регистра) + if new_category_name.lower() not in [c.lower() for c in categories]: + categories.append(new_category_name) + # Обновляем список категорий в данных и сортируем + data['categories'] = sorted(list(set(categories)), key=str.lower) + save_data(data) + flash(f"Категория '{new_category_name}' успешно добавлена.", "success") + upload_db_to_hf(DATA_FILE) # Бэкап + else: + flash(f"Категория '{new_category_name}' уже существует.", "warning") + return redirect(url_for('admin_panel')) @app.route('/admin/category/delete', methods=['POST']) def delete_category(): - data = load_data(); cats = data.get('categories', []); cat_del = request.form.get('category_to_delete') - if cat_del == 'Без категории': flash("Нельзя удалить 'Без категории'.", "danger"); return redirect(url_for('admin_panel')) - orig_name, found = None, False - for c in cats: - if c.lower() == cat_del.lower(): orig_name, found = c, True; break - if found and orig_name: - cats.remove(orig_name); data['categories'] = sorted(cats, key=str.lower); upd_c = 0; ctime = get_current_time().isoformat() - mats = data.get('materials', []) - for m in mats: - if m.get('category', 'Без категории') == orig_name: m['category'], m['timestamp_last_updated'], upd_c = 'Без категории', ctime, upd_c + 1 - if upd_c > 0: data['materials'] = mats - save_data(data); flash(f"Категория '{orig_name}' удалена.", "success") - if upd_c > 0: flash(f"{upd_c} материалов перенесены в 'Без категории'.", "info") - upload_db_to_hf(DATA_FILE) - elif not cat_del: flash("Не выбрана категория.", "warning") - else: flash(f"Категория '{cat_del}' не найдена.", "warning") + data = load_data() + categories = data.get('categories', []) + category_to_delete = request.form.get('category_to_delete') # Имя категории для удаления + + if not category_to_delete: + flash("Не выбрана категория для удаления.", "warning") + return redirect(url_for('admin_panel')) + + if category_to_delete == 'Без категории': + flash("Нельзя удалить системную категорию 'Без категории'.", "danger") + return redirect(url_for('admin_panel')) + + # Находим точное имя категории (с учетом регистра) для удаления + original_category_name = None + category_found = False + for cat in 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) # Сохраняем отсортированный список + + # Обновляем материалы, которые принадлежали этой категории + 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: + mat['category'] = 'Без категории' # Переносим в базовую категорию + mat['timestamp_last_updated'] = update_time + materials_updated_count += 1 + + if materials_updated_count > 0: + data['materials'] = current_materials # Сохраняем изменения в материалах + + save_data(data) + flash(f"Категория '{original_category_name}' успешно удалена.", "success") + if materials_updated_count > 0: + flash(f"{materials_updated_count} материалов были перенесены в категорию 'Без категории'.", "info") + upload_db_to_hf(DATA_FILE) # Бэкап + else: + flash(f"Категория '{category_to_delete}' не найдена.", "warning") + return redirect(url_for('admin_panel')) @app.route('/backup', methods=['POST']) def backup_hf(): + """Инициирует ручное резервное копирование обоих файлов на Hugging Face.""" + files_uploaded_count = 0 try: - logging.info("Ручной бэкап..."); files_up = 0 - with data_lock: - if os.path.exists(DATA_FILE): upload_db_to_hf(DATA_FILE); files_up += 1 - else: flash(f"{DATA_FILE} не найден.", "warning") - with client_data_lock: - if os.path.exists(CLIENT_DATA_FILE): upload_db_to_hf(CLIENT_DATA_FILE); files_up += 1 - else: flash(f"{CLIENT_DATA_FILE} не найден.", "warning") - if files_up > 0: flash(f"Резервное копирование {files_up} файлов инициировано.", "success") - else: flash("Нет файлов для бэкапа.", "warning") - except Exception as e: logging.error(f"Ошибка ручного бэкапа: {e}"); flash(f"Ошибка бэкапа: {e}", "danger") + logging.info("Запуск ручного резервного копирования на Hugging Face...") + # Бэкап основного файла данных + with data_lock: # Используем блокировку на всякий случай + if os.path.exists(DATA_FILE): + upload_db_to_hf(DATA_FILE) + files_uploaded_count += 1 + else: + flash(f"Локальный файл '{DATA_FILE}' не найден для бэкапа.", "warning") + + # Бэкап файла клиентов + with client_data_lock: # Используем блокировку + if os.path.exists(CLIENT_DATA_FILE): + upload_db_to_hf(CLIENT_DATA_FILE) + files_uploaded_count += 1 + else: + flash(f"Локальный файл '{CLIENT_DATA_FILE}' не найден для бэкапа.", "warning") + + if files_uploaded_count > 0: + flash(f"Резервное копирование {files_uploaded_count} файлов на Hugging Face инициировано.", "success") + else: + flash("Не найдено локальных файлов для инициации резервного копирования.", "warning") + + except Exception as e: + logging.error(f"Ошибка во время ручного резервного копирования: {e}", exc_info=True) + flash(f"Произошла ошибка во время резервного копирования: {e}", "danger") + return redirect(url_for('admin_panel')) @app.route('/download', methods=['GET']) def download_hf(): - dwn_files, errors = [], [] - try: logging.info(f"Скачивание {DATA_FILE}..."); hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True); dwn_files.append(DATA_FILE) - except RepositoryNotFoundError: errors.append(f"Репозиторий {REPO_ID} не найден.") - except HfHubHTTPError as e: errors.append(f"HTTP Ошибка {DATA_FILE}: {e.response.status_code}") if e.response.status_code == 404 else errors.append(f"HTTP Ошибка {DATA_FILE}: {e}"); logging.error(f"HTTP Ошибка {DATA_FILE}: {e}") - except Exception as e: logging.error(f"Ошибка скачивания {DATA_FILE}: {e}"); errors.append(f"Ошибка {DATA_FILE}: {e}") - try: logging.info(f"Скачивание {CLIENT_DATA_FILE}..."); hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True); dwn_files.append(CLIENT_DATA_FILE) - except RepositoryNotFoundError: pass # Ошибка уже записана - except HfHubHTTPError as e: errors.append(f"HTTP Ошибка {CLIENT_DATA_FILE}: {e.response.status_code}") if e.response.status_code == 404 else errors.append(f"HTTP Ошибка {CLIENT_DATA_FILE}: {e}"); logging.error(f"HTTP Ошибка {CLIENT_DATA_FILE}: {e}") - except Exception as e: logging.error(f"Ошибка скачивания {CLIENT_DATA_FILE}: {e}"); errors.append(f"Ошибка {CLIENT_DATA_FILE}: {e}") - if dwn_files: flash(f"Файлы ({', '.join(dwn_files)}) успешно скачаны.", "success") - if errors: flash("Ошибки скачивания: " + "; ".join(errors), "danger") - if not dwn_files and not errors: flash("Не удалось инициировать скачивание.", "warning") + """Скачивает оба файла данных с Hugging Face, перезаписывая локальные.""" + downloaded_files = [] + errors = [] + + logging.info("Запуск скачивания данных с Hugging Face...") + # Скачивание основного файла данных + try: + logging.info(f"Попытка скачивания {DATA_FILE}...") + hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, + local_dir=".", local_dir_use_symlinks=False, force_download=True) + downloaded_files.append(DATA_FILE) + logging.info(f"{DATA_FILE} успешно скачан.") + except RepositoryNotFoundError: + msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." + logging.error(msg) + errors.append(msg) + except HfHubHTTPError as e: + if e.response.status_code == 404: + msg = f"Файл '{DATA_FILE}' не найден в репозитории '{REPO_ID}'." + logging.warning(msg) + errors.append(msg) + else: + msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {DATA_FILE}: {e}" + logging.error(msg) + errors.append(msg) + except Exception as e: + msg = f"Неизвестная ошибка при скачивании {DATA_FILE}: {e}" + logging.error(msg, exc_info=True) + errors.append(msg) + + # Скачивание файла клиентов (даже если первый файл не скачался) + try: + logging.info(f"Попытка скачивания {CLIENT_DATA_FILE}...") + hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, + local_dir=".", local_dir_use_symlinks=False, force_download=True) + downloaded_files.append(CLIENT_DATA_FILE) + logging.info(f"{CLIENT_DATA_FILE} успешно скачан.") + except RepositoryNotFoundError: + # Ошибка репозитория уже должна была быть залогирована ��ыше + if not any(f"Репозиторий '{REPO_ID}' не найден" in err for err in errors): + msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face." + logging.error(msg) + errors.append(msg) + except HfHubHTTPError as e: + if e.response.status_code == 404: + msg = f"Файл '{CLIENT_DATA_FILE}' не найден в репозитории '{REPO_ID}'." + logging.warning(msg) + errors.append(msg) + else: + msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {CLIENT_DATA_FILE}: {e}" + logging.error(msg) + errors.append(msg) + except Exception as e: + msg = f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE}: {e}" + logging.error(msg, exc_info=True) + errors.append(msg) + + # Вывод сообщений пользователю + if downloaded_files: + flash(f"Файлы ({', '.join(downloaded_files)}) успешно скачаны и перезаписаны локально.", "success") + if errors: + flash("Во время скачивания произошли ошибки: " + "; ".join(errors), "danger") + if not downloaded_files and not errors: + # Странная ситуация, возможно, нет файлов или другая проблема + flash("Не удалось инициировать скачивание файлов.", "warning") + + # Перезагрузка данных в память после скачивания (опционально, но может быть полезно) + try: + logging.info("Перезагрузка данных в память после скачивания...") + load_data() + load_client_data() + logging.info("Данные в памяти обновлены.") + except Exception as e: + logging.error(f"Ошибка при перезагрузке данных после скачивания: {e}", exc_info=True) + flash("Внимание: Файлы скачаны, но произошла ошибка при обновлении данных в приложении. Может потребоваться перезапуск.", "warning") + + return redirect(url_for('admin_panel')) + # 8. Маршрут "Отчеты" @app.route('/reports', methods=['GET']) def reports(): - data = load_data(); now = get_current_time() - f_type, sd_s, ed_s = request.args.get('filter', 'month'), request.args.get('start_date'), request.args.get('end_date') - ed, sd = None, None + data = load_data() + now = get_current_time() # Текущее время в Бишкеке + + # Получение параметров фильтрации из URL + filter_type = request.args.get('filter', 'month') # По умолчанию - текущий месяц + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + date_str = request.args.get('date') + month_str = request.args.get('month') + year_str = request.args.get('year') + + start_date_dt = None + end_date_dt = None + try: - if f_type == 'custom' and sd_s and ed_s: sd, ed = BISHKEK_TZ.localize(datetime.strptime(sd_s, '%Y-%m-%d')).replace(hour=0, minute=0, second=0, microsecond=0), BISHKEK_TZ.localize(datetime.strptime(ed_s, '%Y-%m-%d')).replace(hour=23, minute=59, second=59, microsecond=999999) - elif f_type == 'day': d_s = request.args.get('date', now.strftime('%Y-%m-%d')); sd, ed = BISHKEK_TZ.localize(datetime.strptime(d_s, '%Y-%m-%d')).replace(hour=0, minute=0, second=0, microsecond=0), sd.replace(hour=23, minute=59, second=59, microsecond=999999) - elif f_type == 'week': today = now.replace(hour=0, minute=0, second=0, microsecond=0); sd, ed = today - timedelta(days=today.weekday()), sd + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) - elif f_type == 'year': y_s = request.args.get('year', str(now.year)); y_i = int(y_s); sd, ed = BISHKEK_TZ.localize(datetime(y_i, 1, 1)), BISHKEK_TZ.localize(datetime(y_i, 12, 31, 23, 59, 59, 999999)) - else: m_s = request.args.get('month', now.strftime('%Y-%m')); y, m = map(int, m_s.split('-')); sd = BISHKEK_TZ.localize(datetime(y, m, 1)); nxt_m = sd.replace(day=28) + timedelta(days=4); end_m = nxt_m - timedelta(days=nxt_m.day); ed = end_m.replace(hour=23, minute=59, second=59, microsecond=999999) - if not sd or not ed or sd > ed: raise ValueError("Некорректный диапазон дат") - except ValueError as e: - flash(f"Ошибка в датах: {e}. Отчет за тек. месяц.", "warning"); f_type = 'month'; sd = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - nxt_m = sd.replace(day=28) + timedelta(days=4); end_m = nxt_m - timedelta(days=nxt_m.day); ed = end_m.replace(hour=23, minute=59, second=59, microsecond=999999) - - filt_packs = [] - all_packs = data.get('qc_packing_items', []) - for item_id in [i['id'] for i in all_packs]: - idata = find_item_by_id(item_id, 'qc_packing_items') - if not idata: continue - ptime = parse_iso_datetime(idata.get('timestamp_packed')) - if ptime and sd <= ptime <= ed: - stime = None; dtls = idata.get('shipment_details') - if dtls and dtls.get('timestamp'): stime = parse_iso_datetime(dtls.get('timestamp')) - idata['shipment_time_dt'] = stime; filt_packs.append(idata) - - filt_defs = [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')] - filt_defs = [d for d in filt_defs if d and d.get('timestamp') and sd <= parse_iso_datetime(d.get('timestamp')) <= ed] - filt_exps = [find_item_by_id(e['id'], 'expenses') for e in data.get('expenses',[]) if find_item_by_id(e['id'], 'expenses')] - filt_exps = [e for e in filt_exps if e and e.get('timestamp') and sd <= parse_iso_datetime(e.get('timestamp')) <= ed] - - tot_qty, tot_rev, tot_mat, tot_sal = sum(i.get('quantity',0) for i in filt_packs), sum(i.get('packed_final_price',Decimal('0')) for i in filt_packs), sum(i.get('packed_material_cost',Decimal('0')) for i in filt_packs), sum(i.get('packed_salary_cost',Decimal('0')) for i in filt_packs) - tot_cost_p, tot_def_c, tot_exp = tot_mat + tot_sal, sum(d.get('cost_dec',Decimal('0')) for d in filt_defs), sum(e.get('amount',Decimal('0')) for e in filt_exps) - tot_ovr_c, tot_prof = tot_cost_p + tot_def_c + tot_exp, tot_rev - (tot_cost_p + tot_def_c + tot_exp) - prod_sum = {} - for item in filt_packs: - n, q, r, c = item.get('product_name','?'), item.get('quantity',0), item.get('packed_final_price',Decimal('0')), item.get('packed_total_cost',Decimal('0')) - p = r - c - if n not in prod_sum: prod_sum[n] = {'quantity':0, 'revenue':Decimal('0'), 'cost':Decimal('0'), 'profit':Decimal('0')} - prod_sum[n]['quantity'] += q; prod_sum[n]['revenue'] += r; prod_sum[n]['cost'] += c; prod_sum[n]['profit'] += p - - rep_data = {'total_packed_qty':tot_qty, 'total_revenue':tot_rev, 'total_material_cost':tot_mat, 'total_salary_cost':tot_sal, - 'total_cost_packed':tot_cost_p, 'total_defect_cost':tot_def_c, 'total_expenses':tot_exp, 'total_overall_cost':tot_ovr_c, - 'total_profit':tot_prof, 'production_summary':prod_sum, 'filtered_packed_items':filt_packs, 'filtered_defects':filt_defs, - 'filtered_expenses':filt_exps, 'start_date':sd.strftime('%Y-%m-%d'), 'end_date':ed.strftime('%Y-%m-%d'), - 'filter_type':f_type, 'current_day':now.strftime('%Y-%m-%d'), 'current_month':now.strftime('%Y-%m'), - 'current_year':now.year, 'filter_values':request.args} + # --- Определение временного диапазона --- + if filter_type == 'custom' and start_date_str and end_date_str: + sd = datetime.strptime(start_date_str, '%Y-%m-%d') + ed = datetime.strptime(end_date_str, '%Y-%m-%d') + # Устанавливаем время: начало дня для start_date, конец дня для end_date + start_date_dt = BISHKEK_TZ.localize(sd.replace(hour=0, minute=0, second=0, microsecond=0)) + end_date_dt = BISHKEK_TZ.localize(ed.replace(hour=23, minute=59, second=59, microsecond=999999)) + + elif filter_type == 'day': + # Если дата не указана, берем сегодняшний день + day_to_use_str = date_str if date_str else now.strftime('%Y-%m-%d') + d = datetime.strptime(day_to_use_str, '%Y-%m-%d') + start_date_dt = BISHKEK_TZ.localize(d.replace(hour=0, minute=0, second=0, microsecond=0)) + end_date_dt = start_date_dt.replace(hour=23, minute=59, second=59, microsecond=999999) + + elif filter_type == 'week': + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + # Начало недели (понедельник) + start_date_dt = today_start - timedelta(days=today_start.weekday()) + # Конец недели (воскресенье, конец дня) + end_date_dt = start_date_dt + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) + + elif filter_type == 'year': + # Если год не указан, берем текущий + year_to_use_str = year_str if year_str else str(now.year) + year_int = int(year_to_use_str) + start_date_dt = BISHKEK_TZ.localize(datetime(year_int, 1, 1, 0, 0, 0)) + end_date_dt = BISHKEK_TZ.localize(datetime(year_int, 12, 31, 23, 59, 59, 999999)) + + else: # По умолчанию 'month' + # Если месяц не указан, берем текущий + month_to_use_str = month_str if month_str else now.strftime('%Y-%m') + year, month = map(int, month_to_use_str.split('-')) + # Начало месяца + start_date_dt = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0)) + # Конец месяца (хитрый способ найти последний день) + next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) + end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) + + # Проверка корректности диапазона + if not start_date_dt or not end_date_dt or start_date_dt > end_date_dt: + raise ValueError("Некорректный временной диапазон.") + + except (ValueError, TypeError) as e: + flash(f"Ошибка в задании периода: {e}. Отображен отчет за текущий месяц.", "warning") + # Возвращаемся к текущему месяцу по умолчанию + filter_type = 'month' + start_date_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) + end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) + + # --- Фильтрация данных по дате --- + + # Упакованные изделия (фильтруем по 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) + + # Брак (фильтруем по 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) + + # Дополнительные расходы (фильтруем по 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) + + # --- Расчет итоговых показателей за период --- + total_packed_quantity = sum(item.get('quantity', 0) for item in filtered_packed_items) + total_revenue = sum(item.get('packed_final_price', Decimal('0')) for item in filtered_packed_items) + total_material_cost_packed = sum(item.get('packed_material_cost', Decimal('0')) for item in filtered_packed_items) + total_salary_cost_packed = sum(item.get('packed_salary_cost', Decimal('0')) for item in filtered_packed_items) + total_cost_packed = total_material_cost_packed + total_salary_cost_packed # Себестоимость упакованных + + total_defect_cost = sum(defect.get('cost_dec', Decimal('0')) for defect in filtered_defects) # Стоимость брака + total_expenses_cost = sum(expense.get('amount', Decimal('0')) for expense in filtered_expenses) # Доп расходы + + total_overall_cost = total_cost_packed + total_defect_cost + total_expenses_cost # Все затраты + total_profit = total_revenue - total_overall_cost # Прибыль + + # --- Сводка по продуктам --- + production_summary = {} + for item in filtered_packed_items: + product_name = item.get('product_name', 'Неизвестный продукт') + quantity = item.get('quantity', 0) + revenue = item.get('packed_final_price', Decimal('0')) + cost = item.get('packed_total_cost', Decimal('0')) # Используем общую себестоимость партии + profit = revenue - cost + + if product_name not in production_summary: + production_summary[product_name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')} + + production_summary[product_name]['quantity'] += quantity + production_summary[product_name]['revenue'] += revenue + production_summary[product_name]['cost'] += cost + production_summary[product_name]['profit'] += profit + + # --- Подготовка данных для шаблона --- + report_data = { + 'total_packed_qty': total_packed_quantity, + 'total_revenue': total_revenue, + 'total_material_cost': total_material_cost_packed, + 'total_salary_cost': total_salary_cost_packed, + 'total_cost_packed': total_cost_packed, + 'total_defect_cost': total_defect_cost, + 'total_expenses': total_expenses_cost, + 'total_overall_cost': total_overall_cost, + 'total_profit': total_profit, + 'production_summary': production_summary, # Словарь со сводкой + 'filtered_packed_items': filtered_packed_items, # Список упакованных + 'filtered_defects': filtered_defects, # Список брака + 'filtered_expenses': filtered_expenses, # Список доп. расходов + # Параметры фильтра для отображения и сохранения состояния формы + 'start_date': start_date_dt.strftime('%Y-%m-%d'), + 'end_date': end_date_dt.strftime('%Y-%m-%d'), + 'filter_type': filter_type, + 'current_day': now.strftime('%Y-%m-%d'), + 'current_month': now.strftime('%Y-%m'), + 'current_year': now.year, + 'filter_values': request.args # Передаем все GET-параметры для удобства + } + html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS) - return render_template_string(html, report=rep_data) + return render_template_string(html, report=report_data) # Передаем весь словарь report_data # --- HTML Шаблоны --- @@ -1058,7 +2119,7 @@ __SCRIPTS__ """ -# Контент закупки (без изменений) +# Контент закупки PROCUREMENT_CONTENT = """
Добавить закупленные материалы
@@ -1118,7 +2179,7 @@ PROCUREMENT_CONTENT = """
""" -# Скрипты закупки (без изменений) +# Скрипты закупки PROCUREMENT_SCRIPTS = """ """ -# Контент раскроя (БЕЗ ФУРНИТУРЫ, БЕЗ ЦЕН/ЗП) +# Контент раскроя CUTTING_CONTENT = """
Регистрация раскроя
@@ -1175,7 +2236,7 @@ CUTTING_CONTENT = """
""" -# Скрипты раскроя (БЕЗ ФУРНИТУРЫ) +# Скрипты раскроя CUTTING_SCRIPTS = """ """ -# Контент пошива (С ФУРНИТУРОЙ и БРАКОМ, БЕЗ ЦЕН/ЗП) +# Контент пошива SEWING_CONTENT = """
Регистрация пошива
@@ -1311,7 +2372,7 @@ SEWING_CONTENT = """
""" -# Скрипты пошива (С ФУРНИТУРОЙ и БРАКОМ, БЕЗ ЦЕН/ЗП) +# Скрипты пошива SEWING_SCRIPTS = """ """ -# Контент ОТК (без изменений) +# Контент ОТК QC_PACKING_CONTENT = """
ОТК и Упаковка готовых изделий
@@ -1436,7 +2497,7 @@ QC_PACKING_CONTENT = """
""" -# Скрипты ОТК (без изменений) +# Скрипты ОТК QC_PACKING_SCRIPTS = """ """ -# Контент Базы Клиентов (исправлена ошибка итерации) +# Контент Базы Клиентов CLIENTS_CONTENT = """

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

@@ -1526,7 +2596,7 @@ CLIENTS_CONTENT = """ - {% for client in clients %} + {% for client in clients %} {# Цикл по клиентам #} {{ client.id[:8] }}... {{ client.name }} @@ -1542,7 +2612,7 @@ CLIENTS_CONTENT = """ Клиенты еще не добавлены. - {% endfor %} + {% endfor %} {# Конец цикла по клиентам #}
@@ -1550,7 +2620,7 @@ CLIENTS_CONTENT = """ -{% for client in clients %} +{% for client in clients %} {# Цикл по клиентам для создания модальных окон #} -{% endfor %} +{% endfor %} {# Конец цикла по клиентам для модальных окон #} """ -# Скрипты Базы Клиентов (без изменений) + +# Скрипты Базы Клиентов CLIENTS_SCRIPTS = """ """ -# Контент Админ-панели (Обновлены таблицы для скрытия цен/зп) +# Контент Админ-панели ADMIN_CONTENT = """

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

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

- +
Материалы

{{ materials_count }}

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

{{ pending_cutting_count }}

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

{{ pending_qc_count }}

заданий пошива ({{ sewing_tasks|selectattr('status', 'equalto', 'pending_qc')|map(attribute='sewn_quantity')|sum }} шт. в них)
-
Упаковано Всего

{{ total_packed_count }}

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

{{ items_ready_ship_qty }}

шт. в {{ items_ready_ship_count }} партиях
+
Ожидают ОТК

{{ pending_qc_count }}

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

{{ format_integer_py(total_packed_count) }}

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

{{ format_integer_py(items_ready_ship_qty) }}

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

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

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

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

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

за все время
- +
Настройки зарплат и маржи
@@ -1663,7 +2794,7 @@ ADMIN_CONTENT = """
- +
Управление категориями материалов
@@ -1671,7 +2802,7 @@ ADMIN_CONTENT = """
- +
Дополнительные расходы
@@ -1694,7 +2825,7 @@ ADMIN_CONTENT = """ - {% for expense in expenses|reverse %} + {% for expense in expenses|sort(attribute='timestamp', reverse=True) %} {# Сортировка по дате #} @@ -1743,6 +2874,7 @@ ADMIN_CONTENT = """ + {# Используем data-sort для корректной числовой сортировки #} @@ -1757,7 +2889,7 @@ ADMIN_CONTENT = """
IDОписаниеСумма (сом)Дата
{{ expense.id[:8] }}... {{ expense.description }}{{ m.id[:8] }}...{{ m.name }} {{ m.category | default('Без ка��егории') }} {{ 'Ткань' if m.type == 'fabric' else 'Фурнитура' }}{{ format_currency_py(m.quantity) if m.type == 'fabric' else format_integer_py(m.quantity) }}{{ m.unit }} {{ format_currency_py(m.price_per_unit) }} {{ format_integer_py(m.items_per_unit) }}
- {% for task in cutting_tasks|reverse %} + {% for task in cutting_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #} @@ -1775,7 +2907,7 @@ ADMIN_CONTENT = """
IDТканьКол-воРасходСтатусСозданоЗавершено
{{ task.id[:8] }}... {{ task.fabric_name }}({{ task.fabric_id[:6] }}...)
- {% for task in sewing_tasks|reverse %} + {% for task in sewing_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #} @@ -1793,7 +2925,7 @@ ADMIN_CONTENT = """
IDИзделиеСшитоИсп. фурн.Брак пошиваСтатусУпак./Брак ОТКСозданоЗавершеноРаскрой ID
{{ task.id[:8] }}... {{ task.product_name }}{{ format_integer_py(task.sewn_quantity) }}
- {% for item in packed_items|reverse %} - {% set qty = item.quantity if item.quantity > 0 else 1 %} - {% set cost_per_item = item.packed_total_cost / qty %} - {% set price_per_item = item.packed_final_price / qty %} + {% for item in packed_items|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #} + {% set qty = item.quantity if item.quantity > 0 else 1 %} {# item.quantity уже int #} + {% set cost_per_item = item.packed_total_cost / qty %} {# item.packed_total_cost уже Decimal #} + {% set price_per_item = item.packed_final_price / qty %} {# item.packed_final_price уже Decimal #} @@ -1844,7 +2976,7 @@ ADMIN_CONTENT = """ {% else %}{% endfor %}
IDНазваниеКол-воСебест. (ед.)Цена (ед.)Общая себест.Общая ценаСтатусДата упак.Детали отправкиПошив ID
{{ item.id[:8] }}...{{ item.product_name }}{{ format_integer_py(item.quantity) }} {{ format_currency_py(cost_per_item) }}
Нет упакованных изделий.
- +
Готово к отправке
{% if items_ready_to_ship %} @@ -1852,7 +2984,7 @@ ADMIN_CONTENT = """ IDИ��делиеКол-воСебест.(общ)Цена (общ)Дата упак.Действия - {% for item in items_ready_to_ship|reverse %} + {% for item in items_ready_to_ship|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #} {{ item.id[:8] }}... {{ item.product_name }} @@ -1873,7 +3005,7 @@ ADMIN_CONTENT = """ """ -# Скрипты Админ-панели (без изменений) +# Скрипты Админ-панели ADMIN_SCRIPTS = """ """ -# Контент Отчетов (без изменений) +# Контент Отчетов REPORTS_CONTENT = """

Отчеты

@@ -2156,6 +3297,7 @@ REPORTS_CONTENT = """ {% set details = item.shipment_details %} {% if details %} + {# Используем поле _dt для форматированной даты отправки #} {{ item.shipment_time_dt.strftime('%Y-%m-%d %H:%M') if item.shipment_time_dt else details.get('timestamp', '')[:16]|replace('T',' ') }} {% if details.get('type') == 'client' %}
Клиент: {{ details.get('client_name', 'N/A')}} @@ -2163,8 +3305,8 @@ REPORTS_CONTENT = """
Назначение: {{ details.get('destination', 'Дордой') }} {% endif %}
- {% elif item.status == 'packed_ready_to_ship' %} - - {% else %} - {% endif %} + {% elif item.status == 'packed_ready_to_ship' %} - {# Готово, но не отправлено #} + {% else %} - {# Другой статус или нет данных #} {% endif %} {{ item.sewing_task_id[:8] }}... {% else %}Нет упакованных изделий за этот период.{% endfor %} @@ -2208,31 +3350,100 @@ REPORTS_CONTENT = """
""" -# Скрипты Отчетов (без изменений) +# Скрипты Отчетов REPORTS_SCRIPTS = """ """ @@ -2262,7 +3473,9 @@ if __name__ == '__main__': logging.info("Данные успешно загружены/инициализированы.") except Exception as e: logging.critical(f"Не удалось загрузить базы данных при запуске: {e}", exc_info=True) + # В зависимости от критичности, можно либо выйти, либо продолжить с пустыми данными + # exit(1) # Раскомментировать для выхода при ошибке загрузки logging.info("Запуск Flask приложения на http://0.0.0.0:7860") + # use_reloader=False важно при использовании бэкап-потока, чтобы он не перезапускался дважды app.run(debug=True, host='0.0.0.0', port=7860, use_reloader=False) -