Kgshop's picture
Create app.py
15808f8 verified
raw
history blame
322 kB
# Импортируем необходимые библиотеки
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
import json
import os
import logging
import threading
import time
from datetime import datetime, timedelta, date
import pytz # Для часовых поясов
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
import uuid # Для генерации уникальных ID
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP # Для точной работы с деньгами и метрами
import functools # Для кэширования клиента
from PIL import Image # Для создания миниатюр
import io # Для работы с байтами изображений
# --- Настройки приложения ---
app = Flask(__name__)
app.secret_key = os.urandom(24) # Необходим для flash сообщений
DATA_FILE = 'data.json' # Основной файл данных
CLIENT_DATA_FILE = 'clients.json' # Файл данных клиентов
UPLOAD_FOLDER = 'uploads' # Папка для загруженных файлов
THUMBNAIL_FOLDER = os.path.join(UPLOAD_FOLDER, 'thumbnails') # Папка для миниатюр
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(THUMBNAIL_FOLDER, exist_ok=True) # Создаем папку для миниатюр
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # Ограничение 16MB на загрузку файла
# --- Настройки Hugging Face ---
# !!! ВАЖНО: Установите переменные окружения HF_TOKEN_WRITE и HF_TOKEN_READ !!!
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE") # Замените или установите переменную окружения
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE") # Замените или установите переменную окружения
REPO_ID = "Kgshop/testbasebase" # !!! Обновленный REPO_ID !!!
# --- Часовой пояс ---
BISHKEK_TZ = pytz.timezone('Asia/Bishkek')
# --- Настройка логирования ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Блокировки для безопасной работы с файлами ---
data_lock = threading.Lock()
client_data_lock = threading.Lock()
# --- Вспомогательные функции для работы с данными ---
def get_current_time():
"""Возвращает текущее время в Бишкекском часовом поясе."""
return datetime.now(BISHKEK_TZ)
def load_data():
"""Загружает основные данные из JSON файла, скачивая с Hugging Face при необходимости."""
with data_lock:
# Попытка скачивания основного файла данных
try:
logging.info(f"Попытка скачивания {DATA_FILE} из репозитория {REPO_ID}...")
hf_hub_download(
repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=".", local_dir_use_symlinks=False, force_download=True,
)
logging.info(f"{DATA_FILE} успешно скачан из Hugging Face.")
except RepositoryNotFoundError:
logging.warning(f"Репозиторий {REPO_ID} не найден. Проверяем локальный {DATA_FILE}.")
except HfHubHTTPError as e:
if e.response.status_code == 404:
logging.warning(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}. Проверяем локальный файл.")
else:
logging.error(f"Ошибка HTTP при скачивании {DATA_FILE} из Hugging Face: {e}")
except Exception as e:
logging.error(f"Неизвестная ошибка при скачивании {DATA_FILE} из Hugging Face: {e}")
# Загрузка из локального файла
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info("Основные данные успешно загружены из локального JSON.")
if not isinstance(data, dict):
logging.warning(f"{DATA_FILE} не является словарем, инициализация пустой структурой.")
return initialize_data_structure()
# Инициализация недостающих ключей верхнего уровня
default_data = initialize_data_structure()
changed = False
for key in default_data.keys():
if key not in data:
logging.warning(f"В {DATA_FILE} отсутствует ключ '{key}'. Инициализация значением по умолчанию.")
data[key] = default_data[key]
changed = True
elif not isinstance(data[key], type(default_data[key])):
logging.warning(f"В {DATA_FILE} ключ '{key}' имеет неверный тип ({type(data[key])} вместо {type(default_data[key])}). Инициализация значением по умолчанию.")
data[key] = default_data[key]
changed = True
# Дополнительно проверяем config
if 'config' not in data or not isinstance(data['config'], dict):
logging.warning(f"В {DATA_FILE} отсутствует или некорректен ключ 'config'. Инициализация значением по умолчанию.")
data['config'] = default_data['config']
changed = True
else:
for config_key, default_value in default_data['config'].items():
if config_key not in data['config']:
logging.warning(f"В {DATA_FILE}['config'] отсутствует ключ '{config_key}'. Инициализация значением по умолчанию.")
data['config'][config_key] = default_value
changed = True
elif not isinstance(data['config'][config_key], str): # В конфиге храним строки
logging.warning(f"В {DATA_FILE}['config'] ключ '{config_key}' имеет неверный тип ({type(data['config'][config_key])} вместо str). Попытка преобразования в строку.")
try:
data['config'][config_key] = str(data['config'][config_key])
changed = True # Изменили тип
except Exception:
logging.error(f"Не удалось преобразовать значение config '{config_key}' в строку. Установка значения по умолчанию.")
data['config'][config_key] = default_value
changed = True
# Сохраняем, если структура была изменена при загрузке
if changed:
logging.info(f"Структура файла {DATA_FILE} была обновлена при загрузке. Сохранение изменений...")
save_data(data) # Вызываем save_data здесь же, внутри lock
return data
except FileNotFoundError:
logging.warning(f"Локальный файл {DATA_FILE} не найден. Инициализация пустой структурой.")
return initialize_data_structure()
except json.JSONDecodeError:
logging.error(f"Ошибка декодирования JSON в файле {DATA_FILE}. Инициализация пустой структурой.")
try:
bad_file_path = f"{DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad"
os.rename(DATA_FILE, bad_file_path)
logging.info(f"Поврежденный файл {DATA_FILE} переименован в {bad_file_path}")
except Exception as backup_err:
logging.error(f"Не удалось создать бэкап поврежденного файла {DATA_FILE}: {backup_err}")
return initialize_data_structure()
except Exception as e:
logging.error(f"Неизвестная ошибка при загрузке локальных основных данных: {e}", exc_info=True)
return initialize_data_structure()
def save_data(data):
"""Сохраняет основные данные в JSON файл."""
# Эта функция вызывается из load_data или маршрутов, уже под data_lock
try:
temp_file = DATA_FILE + ".tmp"
with open(temp_file, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder)
os.replace(temp_file, DATA_FILE)
logging.info(f"Основные данные успешно сохранены в локальный файл {DATA_FILE}.")
except Exception as e:
logging.error(f"Критическая ошибка при сохранении основных данных: {e}", exc_info=True)
if os.path.exists(temp_file):
try: os.remove(temp_file)
except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_file}: {rm_err}")
def load_client_data():
"""Загружает данные клиентов из JSON файла, скачивая с Hugging Face при необходимости."""
with client_data_lock:
try:
logging.info(f"Попытка скачивания {CLIENT_DATA_FILE} из репозитория {REPO_ID}...")
hf_hub_download(
repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=".", local_dir_use_symlinks=False, force_download=True,
)
logging.info(f"{CLIENT_DATA_FILE} успешно скачан из Hugging Face.")
except RepositoryNotFoundError: logging.warning(f"Репозиторий {REPO_ID} не найден. Проверяем локальный {CLIENT_DATA_FILE}.")
except HfHubHTTPError as e:
if e.response.status_code == 404: logging.warning(f"Файл {CLIENT_DATA_FILE} не найден в репозитории {REPO_ID}. Проверяем локальный файл.")
else: logging.error(f"Ошибка HTTP при скачивании {CLIENT_DATA_FILE} из Hugging Face: {e}")
except Exception as e: logging.error(f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE} из Hugging Face: {e}")
try:
with open(CLIENT_DATA_FILE, 'r', encoding='utf-8') as file:
clients = json.load(file)
logging.info("Данные клиентов успешно загружены из локального JSON.")
if not isinstance(clients, list):
logging.warning(f"{CLIENT_DATA_FILE} не является списком, инициализация пустым списком.")
return []
# Проверка структуры каждого клиента
valid_clients = []
changed = False
for client in clients:
if isinstance(client, dict) and 'id' in client and 'name' in client:
client_changed = False
# Проверка и исправление history, если необходимо
if 'history' not in client or not isinstance(client.get('history'), list):
logging.warning(f"Обнаружен некорректный формат 'history' для клиента {client.get('id')} при загрузке. Инициализировано пустым списком.")
client['history'] = []
client_changed = True
else:
# Дополнительная проверка элементов внутри history
valid_history = []
history_changed = False
for record in client['history']:
if isinstance(record, dict) and 'timestamp' in record:
record_changed = False
# Проверка и исправление items
if 'items' not in record or not isinstance(record.get('items'), list):
logging.warning(f"Обнаружен некорректный формат 'items' в записи истории клиента {client.get('id')}, shipment {record.get('shipment_id', 'N/A')}. Инициализировано пустым списком.")
record['items'] = []
record_changed = True
valid_history.append(record)
if record_changed: history_changed = True
else:
logging.warning(f"Обнаружена некорректная запись в истории клиента {client.get('id')}. Пропущена: {record}")
history_changed = True # Считаем изменением, т.к. запись удалена
if history_changed:
client['history'] = valid_history
client_changed = True
valid_clients.append(client)
if client_changed: changed = True
else:
logging.warning(f"Обнаружена некорректная запись клиента в {CLIENT_DATA_FILE}. Пропущена: {client}")
changed = True # Считаем изменением, т.к. запись удалена
# Сохраняем, если структура была изменена при загрузке
if changed:
logging.info(f"Структура файла {CLIENT_DATA_FILE} была обновлена при загрузке. Сохранение изменений...")
save_client_data(valid_clients) # Вызываем save_client_data здесь же, внутри lock
return valid_clients
except FileNotFoundError: logging.warning(f"Локальный файл {CLIENT_DATA_FILE} не найден. Инициализация пустым списком."); return []
except json.JSONDecodeError:
logging.error(f"Ошибка декодирования JSON в файле {CLIENT_DATA_FILE}. Инициализация пустым списком.")
try:
bad_file_path = f"{CLIENT_DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad"
os.rename(CLIENT_DATA_FILE, bad_file_path)
logging.info(f"Поврежденный файл {CLIENT_DATA_FILE} переименован в {bad_file_path}")
except Exception as backup_err:
logging.error(f"Не удалось создать бэкап поврежденного файла {CLIENT_DATA_FILE}: {backup_err}")
return []
except Exception as e: logging.error(f"Неизвестная ошибка при загрузке локальных данных клиентов: {e}", exc_info=True); return []
def save_client_data(clients):
"""Сохраняет данные клиентов в JSON файл."""
# Эта функция вызывается из load_client_data или маршрутов, уже под client_data_lock
if not isinstance(clients, list):
logging.error(f"Попытка сохранить не-список как {CLIENT_DATA_FILE}. Операция отменена.")
return
for i, client in enumerate(clients):
if not isinstance(client, dict) or 'id' not in client:
logging.error(f"Попытка сохранить некорректный объект клиента на позиции {i} в {CLIENT_DATA_FILE}. Операция отменена.")
return
if 'history' in client and not isinstance(client['history'], list):
logging.error(f"Попытка сохранить некорректный history (не список) для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.")
return
if 'history' in client and isinstance(client['history'], list):
for j, record in enumerate(client['history']):
if not isinstance(record, dict):
logging.error(f"Попытка сохранить некорректную запись history (не словарь) на позиции {j} для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.")
return
if 'items' in record and not isinstance(record['items'], list):
logging.error(f"Попытка сохранить некорректные items (не список) в записи history {j} для клиента {client.get('id')} в {CLIENT_DATA_FILE}. Операция отменена.")
return
# Сохранение
try:
temp_file = CLIENT_DATA_FILE + ".tmp"
with open(temp_file, 'w', encoding='utf-8') as file:
json.dump(clients, file, ensure_ascii=False, indent=4) # Не используем DecimalEncoder здесь
os.replace(temp_file, CLIENT_DATA_FILE)
logging.info(f"Данные клиентов успешно сохранены в локальный файл {CLIENT_DATA_FILE}.")
except Exception as e:
logging.error(f"Критическая ошибка при сохранении данных клиентов: {e}", exc_info=True)
if os.path.exists(temp_file):
try: os.remove(temp_file)
except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_file}: {rm_err}")
def initialize_data_structure():
"""Возвращает пустую структуру основных данных по умолчанию."""
return {
'materials': [], 'categories': [], 'cutting_tasks': [], 'sewing_tasks': [],
'qc_packing_items': [], 'defect_log': [], 'expenses': [], 'dordoi_shipments': [],
'cloud_files': [], # Добавлено
'config': {'salary_cutter_per_unit': '0.00', 'salary_sewer_per_unit': '0.00',
'salary_packer_per_unit': '0.00', 'margin_per_item': '0.00'}
}
@functools.lru_cache(maxsize=1)
def get_hf_api():
"""Возвращает инициализированный объект HfApi."""
if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE":
logging.warning("Токен HF_TOKEN_WRITE не установлен. Загрузка на Hugging Face будет недоступна.")
return None
try: return HfApi()
except Exception as e: logging.error(f"Ошибка инициализации HfApi: {e}"); return None
def upload_db_to_hf(filepath=DATA_FILE):
"""Загружает указанный локальный файл данных на Hugging Face."""
api = get_hf_api()
if not api: logging.warning(f"HfApi не инициализирован. Загрузка {filepath} на Hugging Face пропущена."); return
if not os.path.exists(filepath): logging.warning(f"Локальный файл {filepath} не найден. Загрузка на Hugging Face пропущена."); return
try:
filename = os.path.basename(filepath)
commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z')
logging.info(f"Начало загрузки файла {filename} на Hugging Face...")
# Используем run_as_future=True для асинхронной загрузки, чтобы не блокировать основной поток
api.upload_file(
path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset",
token=HF_TOKEN_WRITE, commit_message=f"Автоматическое резервное копирование {filename} {commit_time}",
run_as_future=True
)
logging.info(f"Загрузка файла {filename} на Hugging Face инициирована.")
except RepositoryNotFoundError: logging.error(f"Ошибка загрузки: Репозиторий {REPO_ID} не найден на Hugging Face.")
except Exception as e: logging.error(f"Ошибка при инициации загрузки {filepath} на Hugging Face: {e}")
def periodic_backup():
"""Периодически вызывает upload_db_to_hf для обоих файлов."""
logging.info("Запуск потока периодического резервного копирования.")
while True:
backup_interval = 1800 # 30 минут
logging.debug(f"Периодический бэкап спит {backup_interval} секунд...")
time.sleep(backup_interval)
logging.info("Запуск планового резервного копирования...")
try:
# Блокировка не обязательна, так как upload_db_to_hf читает существующий файл
if os.path.exists(DATA_FILE):
upload_db_to_hf(DATA_FILE)
else:
logging.warning(f"Файл {DATA_FILE} не найден для планового бэкапа.")
if os.path.exists(CLIENT_DATA_FILE):
upload_db_to_hf(CLIENT_DATA_FILE)
else:
logging.warning(f"Файл {CLIENT_DATA_FILE} не найден для планового бэкапа.")
logging.info("Плановое резервное копирование завершено.")
except Exception as e:
logging.error(f"Ошибка во время планового резервного копирования: {e}", exc_info=True)
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal): return str(obj)
return json.JSONEncoder.default(self, obj)
def to_decimal(value_str, default='0.00'):
"""Безопасно преобразует строку в Decimal."""
if value_str is None or value_str == '': return Decimal(default)
try: return Decimal(str(value_str).replace(',', '.'))
except InvalidOperation: logging.warning(f"Не удалось преобразовать '{value_str}' в Decimal. Возвращено {default}."); return Decimal(default)
def parse_iso_datetime(timestamp_str):
"""Преобразует строку ISO в объект datetime со знанием часового пояса."""
if not timestamp_str: return None
try:
# Попытка парсинга напрямую
try: dt = datetime.fromisoformat(timestamp_str)
except ValueError:
# Если не получилось, пытаемся убрать миллисекунды (если они есть)
if '.' in timestamp_str: timestamp_str = timestamp_str.split('.', 1)[0]
dt = datetime.fromisoformat(timestamp_str) # Повторная попытка
# Проверка и установка часового пояса
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
# Если зона не указана, считаем UTC и конвертируем в Бишкек
return pytz.utc.localize(dt).astimezone(BISHKEK_TZ)
else:
# Если зона указана, просто конвертируем в Бишкек
return dt.astimezone(BISHKEK_TZ)
except (ValueError, TypeError) as e:
logging.warning(f"Не удалось разобрать дату: '{timestamp_str}'. Ошибка: {e}")
return None
def find_item_by_id(item_id, item_list_name):
"""Обобщенная функция для поиска элемента по ID в основном списке данных."""
data = load_data() # Загружаем свежие данные при каждом поиске
items = data.get(item_list_name, [])
if not isinstance(items, list): # Доп. проверка
logging.error(f"Ожидался список для '{item_list_name}', но получен {type(items)}. Возврат None.")
return None
for item in items:
if not isinstance(item, dict): # Пропускаем не-словари в списке
logging.warning(f"Обнаружен не-словарь в списке '{item_list_name}': {item}. Пропущен.")
continue
# Проверяем как 'id', так и 'log_id' (для defect_log), так и 'file_id' (для cloud_files)
current_item_id = item.get('id') or item.get('log_id') or item.get('file_id')
if current_item_id == item_id:
item_copy = item.copy() # Возвращаем копию, чтобы не изменять исходные данные
# --- Преобразование типов ---
decimal_fields = []
int_fields = []
try: # Обернем преобразования в try-except для большей устойчивости
if item_list_name == 'materials':
decimal_fields = ['quantity', 'price_per_unit']
int_fields = ['items_per_unit']
elif item_list_name == 'cutting_tasks':
decimal_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost']
int_fields = ['cut_items_quantity']
elif item_list_name == 'sewing_tasks':
# --- ИЗМЕНЕНО: Добавлено поле fittings_cost ---
decimal_fields = ['fittings_cost', 'sewing_salary_cost', 'cutting_salary_cost']
int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity']
# Обработка вложенных структур
if 'fittings_consumed' in item_copy and isinstance(item_copy['fittings_consumed'], list):
for f in item_copy['fittings_consumed']:
if isinstance(f, dict):
# Преобразуем quantity_used в int, cost в Decimal
f['quantity_used'] = int(to_decimal(f.get('quantity_used', '0')))
f['cost'] = to_decimal(f.get('cost', '0.00'))
# --- КОНЕЦ ИЗМЕНЕНИЯ ---
if 'defects_reported' in item_copy and isinstance(item_copy['defects_reported'], list):
for d in item_copy['defects_reported']:
if isinstance(d, dict):
qty_str = d.get('quantity', '0')
defect_type = d.get('type')
d['cost'] = to_decimal(d.get('cost', '0.00'))
# Преобразование quantity в зависимости от типа брака
if defect_type == 'fabric':
d['quantity'] = to_decimal(qty_str) # Оставляем Decimal для ткани
elif defect_type in ['fittings', 'finished_product']:
try: d['quantity'] = int(to_decimal(qty_str)) # Преобразуем в int
except (InvalidOperation, ValueError): d['quantity'] = 0
else: d['quantity'] = 0 # Неизвестный тип
elif item_list_name == 'qc_packing_items':
decimal_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price']
int_fields = ['quantity']
elif item_list_name == 'expenses':
decimal_fields = ['amount']
elif item_list_name == 'defect_log':
decimal_fields = ['cost'] # Основное поле cost
qty_str = item_copy.get('quantity', '0')
defect_type = item_copy.get('type')
item_copy['cost_dec'] = to_decimal(item_copy.get('cost', '0.00'))
if defect_type == 'fabric':
qty_dec = to_decimal(qty_str)
item_copy['quantity_view'] = f"{qty_dec:.2f}".replace('.', ',') # Форматированное для отображения
item_copy['quantity_raw'] = qty_dec # Decimal для расчетов
elif defect_type in ['fittings', 'finished_product']:
try:
qty_int = int(to_decimal(qty_str))
item_copy['quantity_view'] = str(qty_int) # Строка для отображения
item_copy['quantity_raw'] = qty_int # Int для расчетов
except (InvalidOperation, ValueError):
item_copy['quantity_view'] = '0'; item_copy['quantity_raw'] = 0
else: # Неизвестный тип
item_copy['quantity_view'] = str(qty_str); item_copy['quantity_raw'] = qty_str
# Для cloud_files преобразование не требуется
# Применяем преобразования
for field in decimal_fields:
if item_copy.get(field) is not None:
item_copy[field] = to_decimal(item_copy.get(field))
else:
# Устанавливаем Decimal('0.00') по умолчанию для отсутствующих decimal полей
item_copy[field] = Decimal('0.00')
logging.debug(f"Поле Decimal '{field}' отсутствует в {item_list_name} ID {item_id}. Установлено '0.00'.")
for field in int_fields:
if item_copy.get(field) is not None:
item_copy[field] = int(to_decimal(item_copy.get(field, '0')))
else:
# Устанавливаем 0 по умолчанию для отсутствующих int полей
item_copy[field] = 0
logging.debug(f"Поле Int '{field}' отсутствует в {item_list_name} ID {item_id}. Установлено 0.")
except Exception as conversion_error:
logging.error(f"Ошибка преобразования типов для {item_list_name} ID {item_id}: {conversion_error}", exc_info=True)
return None # Возвращаем None при ошибке преобразования
return item_copy
return None # Элемент не найден
def find_client_by_id(client_id):
"""Ищет клиента по ID в базе клиентов."""
clients = load_client_data() # Загружаем свежие и проверенные данные
for client in clients: # clients уже проверен на list в load_client_data
# client уже проверен на dict в load_client_data
if client.get('id') == client_id:
client_copy = client.copy()
# Преобразуем таймстемпы истории для удобства
# history уже проверен на list и его содержимое на dict в load_client_data
if 'history' in client_copy:
for record in client_copy['history']:
# items уже проверен на list в load_client_data
record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp'))
return client_copy
return None
def create_thumbnail(image_path, thumb_path, size=(100, 100)):
"""Создает миниатюру для изображения."""
try:
with Image.open(image_path) as img:
img.thumbnail(size)
# Сохраняем в JPEG для экономии места, игнорируем ошибки если формат не поддерживается
try:
img.save(thumb_path, "JPEG")
logging.info(f"Создана миниатюра: {thumb_path}")
return os.path.basename(thumb_path) # Возвращаем имя файла миниатюры
except OSError as e:
# Если JPEG не поддерживается (например, для GIF), пытаемся сохранить в PNG
try:
img.save(thumb_path, "PNG")
logging.info(f"Создана миниатюра (PNG): {thumb_path}")
return os.path.basename(thumb_path)
except Exception as png_e:
logging.error(f"Не удалось сохранить миниатюру как PNG для {image_path}: {png_e}")
return None
except Exception as e:
logging.error(f"Не удалось сохранить миниатюру как JPEG для {image_path}: {e}")
return None
except Exception as e:
logging.error(f"Ошибка при создании миниатюры для {image_path}: {e}")
return None
# --- Python Helper Functions for Formatting Numbers ---
def format_currency_py(value):
"""Formats a Decimal or string representation as currency (Python side)."""
try:
number = to_decimal(value)
# Формат с пробелом как разделителем тысяч и запятой как десятичным разделителем
formatted_num = f"{number:,.2f}".replace(",", "TEMP_SPACE").replace(".", ",").replace("TEMP_SPACE", " ")
return formatted_num
except (InvalidOperation, TypeError, ValueError):
return "0,00" # Возвращаем строку по умолчанию
def format_integer_py(value):
"""Formats a Decimal or string representation as an integer string (Python side)."""
try:
# Преобразуем в Decimal, затем в целое с округлением
number = to_decimal(value).to_integral_value(rounding=ROUND_HALF_UP)
# Формат с пробелом как разделителем тысяч
return f"{number:,}".replace(",", " ")
except (InvalidOperation, TypeError, ValueError):
return "0" # Возвращаем строку по умолчанию
# --- Python Helper Functions for Status Display ---
def getStatusText(statusKey):
"""Возвращает текстовое представление статуса на русском."""
statusMap = {
'pending': 'Ожидает пошива',
'completed': 'Завершено',
'pending_qc': 'Ожидает ОТК',
'packed_ready_to_ship': 'Готово к отправке',
'shipped_client': 'Отправлено клиенту',
'shipped_dor_doi': 'Отправлено на Дордой',
'pending_procurement': 'Ожидает закупа'
}
return statusMap.get(statusKey, statusKey) # Возвращаем ключ, если статус неизвестен
def getStatusClass(statusKey):
"""Возвращает CSS классы для стилизации статуса."""
classMap = {
'pending': 'status-pending text-info',
'completed': 'status-completed text-success',
'pending_qc': 'status-pending_qc text-warning',
'packed_ready_to_ship': 'status-packed_ready_to_ship text-ready', # Использует .text-ready
'shipped_client': 'status-shipped_client text-shipped-client', # Использует .text-shipped-client
'shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi' # Использует .text-shipped-dordoi
# 'partially_shipped': 'status-partially-shipped text-primary' # Можно добавить
}
return classMap.get(statusKey, '') # Возвращаем пустую строку, если статус неизвестен
# --- Маршруты Flask ---
@app.route('/')
def index():
# Перенаправляем на админ-панель по умолчанию
return redirect(url_for('admin_panel'))
# Маршрут "Заказы"
@app.route('/orders', methods=['GET', 'POST'])
def orders():
data = load_data()
clients_data = load_client_data()
# Get materials for selection
fabrics = [m for m in data.get('materials', [])
if isinstance(m, dict) and m.get('type') == 'fabric']
fittings = [m for m in data.get('materials', [])
if isinstance(m, dict) and m.get('type') == 'fittings']
if request.method == 'POST':
try:
client_id = request.form.get('client_id')
model_name = request.form.get('model_name', '').strip()
fabric_name = request.form.get('fabric_name', '').strip()
fabric_quantity = request.form.get('fabric_quantity')
size_range = request.form.get('size_range', '').strip()
items_quantity = request.form.get('items_quantity')
prepayment = request.form.get('prepayment', '0')
if not all([client_id, model_name, fabric_name, fabric_quantity, items_quantity]):
flash("Заполните все обязательные поля заказа.", "danger")
return redirect(url_for('orders'))
# Find client
client = find_client_by_id(client_id)
if not client:
flash("Выбранный клиент не найден.", "danger")
return redirect(url_for('orders'))
# Create new order
creation_time = get_current_time().isoformat()
new_order = {
'id': uuid.uuid4().hex,
'client_id': client_id,
'client_name': client.get('name', 'N/A'),
'model_name': model_name,
'fabric_name': fabric_name,
'fabric_quantity': fabric_quantity,
'size_range': size_range,
'items_quantity': items_quantity,
'prepayment': to_decimal(prepayment),
'status': 'pending_procurement', # Initial status
'timestamp_created': creation_time,
'timestamp': creation_time,
'is_procured': False, # Explicitly set to False for new orders
'fittings': []
}
# Add fittings if any
fitting_names = request.form.getlist('fitting_names[]')
fitting_quantities = request.form.getlist('fitting_quantities[]')
for i in range(len(fitting_names)):
name = fitting_names[i].strip()
qty = fitting_quantities[i].strip()
if name and qty: # Only add if both name and quantity are provided
new_order['fittings'].append({
'fitting_name': name,
'quantity': qty
})
# Initialize orders list if not exists
if 'orders' not in data:
data['orders'] = []
# Add new order and save
data['orders'].append(new_order)
save_data(data)
flash(f"Заказ на {items_quantity} ед. '{model_name}' успешно создан.", "success")
upload_db_to_hf(DATA_FILE)
return redirect(url_for('orders'))
except Exception as e:
logging.error(f"Ошибка при создании заказа: {e}", exc_info=True)
flash(f"Произошла ошибка при создании заказа: {e}", "danger")
return redirect(url_for('orders'))
if request.method == 'POST':
try:
client_id = request.form.get('client_id')
model_name = request.form.get('model_name', '').strip()
fabric_id = request.form.get('fabric_id')
fabric_quantity = request.form.get('fabric_quantity')
size_range = request.form.get('size_range', '').strip()
items_quantity = request.form.get('items_quantity')
# Получаем фурнитуру из формы
fitting_ids = request.form.getlist('fitting_ids[]')
fitting_quantities = request.form.getlist('fitting_quantities[]')
# Валидация основных полей
if not all([client_id, model_name, fabric_id, fabric_quantity, items_quantity]):
flash("Заполните все обязательные поля заказа.", "danger")
return redirect(url_for('orders'))
# Находим клиента
client = find_client_by_id(client_id)
if not client:
flash("Выбранный клиент не найден.", "danger")
return redirect(url_for('orders'))
# Создаем новый заказ
creation_time = get_current_time().isoformat()
# Create new order
new_order = {
'id': uuid.uuid4().hex,
'client_id': client_id,
'client_name': client.get('name', 'N/A'),
'model_name': model_name,
'fabric_name': request.form.get('fabric_name'), # Store fabric name
'fabric_quantity': fabric_quantity,
'size_range': size_range,
'items_quantity': items_quantity,
'prepayment': to_decimal(request.form.get('prepayment', '0')),
'status': 'pending',
'timestamp_created': creation_time,
'timestamp': creation_time,
'fittings': []
}
# Add fittings
fitting_names = request.form.getlist('fitting_names[]')
fitting_quantities = request.form.getlist('fitting_quantities[]')
for i in range(len(fitting_names)):
name = fitting_names[i].strip()
qty = fitting_quantities[i].strip()
if name and qty:
new_order['fittings'].append({
'fitting_name': name,
'quantity': qty
})
# Initialize orders list if not exists
if 'orders' not in data:
data['orders'] = []
# Add new order and save
data['orders'].append(new_order)
save_data(data)
flash(f"Заказ на {items_quantity} ед. '{model_name}' успешно создан.", "success")
upload_db_to_hf(DATA_FILE)
return redirect(url_for('orders'))
except Exception as e:
logging.error(f"Ошибка при создании заказа: {e}", exc_info=True)
flash(f"Произошла ошибка при создании заказа: {e}", "danger")
return redirect(url_for('orders'))
# GET запрос - отображаем форму и список заказов
orders_list = data.get('orders', [])
for order in orders_list:
if isinstance(order, dict):
# Добавляем информацию о материалах
fabric = find_item_by_id(order.get('fabric_id'), 'materials')
if fabric:
order['fabric_name'] = fabric.get('name', 'N/A')
order['fabric_unit'] = fabric.get('unit', 'м')
# Добавляем информацию о фурнитуре
for f in order.get('fittings', []):
fitting = find_item_by_id(f.get('fitting_id'), 'materials')
if fitting:
f['fitting_name'] = fitting.get('name', 'N/A')
# Убедимся что все заказы имеют timestamp_created
for order in orders_list:
if isinstance(order, dict) and not order.get('timestamp_created'):
order['timestamp_created'] = order.get('timestamp', '') # Используем timestamp как запасной вариант
# Сортируем заказы по дате создания (новые сверху)
orders_list.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True)
html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Заказы").replace('__CONTENT__', ORDERS_CONTENT).replace('__SCRIPTS__', ORDERS_SCRIPTS)
return render_template_string(html,
clients=clients_data,
fabrics=fabrics,
fittings=fittings,
orders=orders_list)
# Маршрут редактирования заказа
@app.route('/orders/edit/<order_id>', methods=['POST'])
def edit_order():
data = load_data()
order_id = request.form.get('order_id')
# Находим заказ для редактирования
orders = data.get('orders', [])
order_index = None
for i, order in enumerate(orders):
if isinstance(order, dict) and order.get('id') == order_id:
order_index = i
break
if order_index is None:
flash("Заказ не найден.", "danger")
return redirect(url_for('orders'))
try:
# Обновляем данные заказа
orders[order_index].update({
'model_name': request.form.get('model_name', '').strip(),
'fabric_id': request.form.get('fabric_id'),
'fabric_quantity': request.form.get('fabric_quantity'),
'size_range': request.form.get('size_range', '').strip(),
'items_quantity': request.form.get('items_quantity'),
})
# Обновляем фурнитуру
fitting_ids = request.form.getlist('fitting_ids[]')
fitting_quantities = request.form.getlist('fitting_quantities[]')
orders[order_index]['fittings'] = []
for i in range(len(fitting_ids)):
if fitting_ids[i] and fitting_quantities[i]:
orders[order_index]['fittings'].append({
'fitting_id': fitting_ids[i],
'quantity': fitting_quantities[i]
})
save_data(data)
flash("Заказ успешно обновлен.", "success")
upload_db_to_hf(DATA_FILE)
except Exception as e:
logging.error(f"Ошибка при обновлении заказа: {e}", exc_info=True)
flash(f"Произошла ошибка при обновлении заказа: {e}", "danger")
return redirect(url_for('orders'))
# Маршрут удаления заказа
@app.route('/orders/delete/<order_id>', methods=['POST'])
def delete_order(order_id):
data = load_data()
orders = data.get('orders', [])
# Находим и удаляем заказ
for i, order in enumerate(orders):
if isinstance(order, dict) and order.get('id') == order_id:
if order.get('status') != 'completed':
del orders[i]
data['orders'] = orders
save_data(data)
flash("Заказ успешно удален.", "success")
upload_db_to_hf(DATA_FILE)
else:
flash("Нельзя удалить выполненный заказ.", "danger")
break
else:
flash("Заказ не найден.", "warning")
return redirect(url_for('orders'))
# 1. Маршрут "Закуп"
@app.route('/procurement', methods=['GET', 'POST'])
def procurement():
data = load_data()
categories = data.get('categories', [])
# Get pending procurement orders (not procured)
pending_orders = [
order for order in data.get('orders', [])
if isinstance(order, dict) and
order.get('status') == 'pending_procurement' and
not order.get('is_procured', False)
]
if request.method == 'POST':
order_id = request.form.get('order_id')
if order_id:
# Find the order
for order in data.get('orders', []):
if isinstance(order, dict) and order.get('id') == order_id:
order['is_procured'] = True
order['status'] = 'pending' # Change status to pending (for cutting)
order['procurement_timestamp'] = get_current_time().isoformat()
save_data(data)
flash(f"Заказ '{order.get('model_name')}' отмечен как закупленный и готов к раскрою.", "success")
upload_db_to_hf(DATA_FILE)
break
return redirect(url_for('procurement'))
# Format orders for display
procurement_orders = []
for order in pending_orders:
if 'material_requirements' in order:
proc_order = {
'id': order.get('id'),
'model_name': order.get('model_name'),
'client_name': order.get('client_name'),
'requirements': order['material_requirements']
}
procurement_orders.append(proc_order)
if request.method == 'POST':
try:
materials_to_add = []
valid_items_processed = 0 # Счетчик успешно обработанных строк
item_names = request.form.getlist('item_name[]')
# Проверка, что хотя бы одна строка была отправлена и не пуста
if not item_names or all(not name.strip() for name in item_names):
flash("Не добавлено ни одного товара. Заполните хотя бы одну строку.", "warning")
return redirect(url_for('procurement'))
item_quantities = request.form.getlist('item_quantity[]')
item_units = request.form.getlist('item_unit[]')
item_prices = request.form.getlist('item_price_per_unit[]')
item_per_unit = request.form.getlist('item_per_unit[]') # Количество изделий на единицу (для фурнитуры)
item_types = request.form.getlist('item_type[]')
item_categories = request.form.getlist('item_category[]')
item_new_categories = request.form.getlist('item_new_category[]')
procurement_time = get_current_time().isoformat()
current_materials = data.get('materials', []) # Получаем текущий список материалов
for i in range(len(item_names)):
name = item_names[i].strip()
quantity_str = item_quantities[i]
unit = item_units[i]
price_str = item_prices[i]
items_per_unit_str = item_per_unit[i]
item_type = item_types[i]
category = item_categories[i]
new_category = item_new_categories[i].strip()
# Пропускаем пустые строки, которые могли быть добавлены динамически
if not name and not quantity_str and not price_str and not category and not new_category:
continue
# Валидация обязательных полей для НЕпустой строки
if not name or not quantity_str or not unit or not price_str or not item_type:
flash(f"Ошибка в строке {i+1}: Необходимо заполнить название, количество, единицу измерения, цену за единицу и тип.", "danger")
continue # Пропускаем эту строку, но продолжаем обработку остальных
# Преобразование и валидация чисел
try:
quantity = to_decimal(quantity_str)
price = to_decimal(price_str)
except InvalidOperation:
flash(f"Ошибка в строке {i+1}: Некорректный формат числа для количества или цены.", "danger")
continue
if quantity <= 0:
flash(f"Ошибка в строке {i+1}: Количество должно быть больше нуля.", "danger")
continue
if price < 0:
flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger")
continue
# Обработка "На ед." (items_per_unit)
items_per_unit = 0
if items_per_unit_str:
try: items_per_unit = int(to_decimal(items_per_unit_str).to_integral_value())
except (InvalidOperation, ValueError): flash(f"Предупреждение в строке {i+1}: Некорректное значение 'На ед.', установлено 0.", "warning"); items_per_unit = 0
if items_per_unit < 0: items_per_unit = 0
# Определение категории
final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории")
# Добавляем новую категорию в общий список, если её там нет
# Убедимся, что работаем со списком строк
current_valid_categories = [c for c in categories if isinstance(c, str)]
if new_category and final_category not in current_valid_categories:
current_valid_categories.append(final_category)
categories = current_valid_categories # Обновляем основной список
# Поиск существующего материала (по названию, типу и категории)
existing_material_index = -1
for idx, mat in enumerate(current_materials):
# Сравниваем lowercase для имени и учитываем тип и категорию
if isinstance(mat, dict) and \
mat.get('name','').lower() == name.lower() and \
mat.get('type') == item_type and \
mat.get('category', 'Без категории') == final_category:
existing_material_index = idx
break
if existing_material_index != -1:
# --- Обновляем существующий материал ---
existing_material = current_materials[existing_material_index]
# Обновляем цену (берем последнюю закупочную)
existing_material['price_per_unit'] = str(price)
# Добавляем количество
current_quantity = to_decimal(existing_material.get('quantity', '0'))
new_quantity = current_quantity + quantity
existing_material['quantity'] = str(new_quantity)
# Обновляем единицу измерения (на всякий случай)
existing_material['unit'] = unit
# Обновляем items_per_unit
existing_material['items_per_unit'] = items_per_unit
# Обновляем время последнего обновления
existing_material['timestamp_last_updated'] = procurement_time
logging.info(f"Материал '{name}' обновлен. Новое количество: {new_quantity}, Цена: {price}, Категория: {final_category}")
valid_items_processed += 1
else:
# --- Добавляем новый материал ---
new_material = {
'id': uuid.uuid4().hex,
'name': name,
'quantity': str(quantity),
'unit': unit,
'price_per_unit': str(price),
'material_per_unit': items_per_unit, # Переименовано: теперь хранит расход материала на единицу продукции
'type': item_type,
'category': final_category,
'timestamp_added': procurement_time,
'timestamp_last_updated': procurement_time
}
materials_to_add.append(new_material)
logging.info(f"Новый материал '{name}' добавлен. Количество: {quantity}, Цена: {price}, Категория: {final_category}")
valid_items_processed += 1
# Сохраняем данные, если хотя бы одна позиция была успешно обработана
if valid_items_processed > 0 :
if materials_to_add:
data['materials'].extend(materials_to_add) # Добавляем новые
# data['materials'] уже содержит обновленные элементы, если были только обновления
# Обновляем и сортируем список категорий (только строки)
data['categories'] = sorted(list(set(c for c in categories if isinstance(c, str))), key=str.lower)
save_data(data)
flash(f"Закуп успешно зарегистрирован! Обработано {valid_items_processed} позиций.", "success")
upload_db_to_hf(DATA_FILE) # Запускаем бэкап
else:
# Если были ошибки во всех строках или все строки были пустые/невалидные
flash("Не было добавлено или обновлено ни одной валидной позиции.", "warning")
return redirect(url_for('procurement'))
except Exception as e:
logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True)
flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger")
return redirect(url_for('procurement'))
# GET запрос: отображаем страницу
# Добавляем форматированные строки для отображения в шаблоне
materials_display = []
for m in data.get('materials', []):
if isinstance(m, dict) and 'id' in m: # Доп. проверка
m_data = find_item_by_id(m['id'], 'materials')
if m_data:
# Форматирование уже есть в find_item_by_id, но оставим для ясности
if m_data.get('type') == 'fabric':
m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00'))
else:
m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0'))
m_data['price_str'] = format_currency_py(m_data.get('price_per_unit', '0.00'))
materials_display.append(m_data)
# Фильтруем категории, оставляем только строки
valid_categories = [c for c in categories if isinstance(c, str)]
# Get pending orders for procurement
pending_orders = [
order for order in data.get('orders', [])
if isinstance(order, dict) and
order.get('status') == 'pending_procurement' and
not order.get('is_procured', False)
]
html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS)
return render_template_string(html, categories=valid_categories, materials_display=materials_display, orders=pending_orders)
# 2. Маршрут "Раскрой"
@app.route('/cutting', methods=['GET', 'POST'])
def cutting():
data = load_data()
# Get pending procurement orders (not procured)
pending_orders = []
for order in data.get('orders', []):
if isinstance(order, dict) and order.get('status') == 'pending_procurement' and not order.get('is_procured', False):
pending_orders.append(order)
# Фильтруем материалы, оставляем только ткани с положительным количеством
fabrics = []
for m in data.get('materials', []):
if isinstance(m, dict) and m.get('type') == 'fabric':
# Используем to_decimal для проверки количества
if to_decimal(m.get('quantity', '0')) > 0:
fabrics.append(m)
config = data.get('config', {})
if request.method == 'POST':
try:
fabric_id = request.form.get('fabric_id')
cut_items_quantity_str = request.form.get('cut_items_quantity')
fabric_used_str = request.form.get('fabric_used')
# Валидация входных данных
if not fabric_id or not cut_items_quantity_str or not fabric_used_str:
flash("Необходимо выбрать ткань и заполнить все поля.", "danger")
return redirect(url_for('cutting'))
# Ищем выбранную ткань в данных
fabric_material = find_item_by_id(fabric_id, 'materials')
if not fabric_material: # find_item_by_id вернет None, если не найдено или ошибка
flash("Выбранная ткань не найдена в базе данных.", "danger")
return redirect(url_for('cutting'))
# Преобразование и валидация чисел
try:
cut_items_quantity = int(to_decimal(cut_items_quantity_str).to_integral_value())
if cut_items_quantity <= 0: raise ValueError("Количество должно быть > 0")
except (InvalidOperation, ValueError):
flash("Некорректное количество раскроенных изделий. Введите целое положительное число.", "danger")
return redirect(url_for('cutting'))
try:
fabric_used = to_decimal(fabric_used_str)
if fabric_used <= 0: raise ValueError("Расход ткани должен быть > 0")
except (InvalidOperation, ValueError):
flash("Некорректное значение использованной ткани. Введите положительное число.", "danger")
return redirect(url_for('cutting'))
# Проверка наличия достаточного количества ткани
# find_item_by_id уже вернул quantity как Decimal
available_quantity = fabric_material.get('quantity', Decimal('0.00'))
if fabric_used > available_quantity:
flash(f"Недостаточно ткани '{fabric_material['name']}'. "
f"В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, "
f"требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger")
return redirect(url_for('cutting'))
# Расчет стоимостей (на основе текущих данных)
# find_item_by_id уже вернул price_per_unit как Decimal
price_per_unit = fabric_material.get('price_per_unit', Decimal('0.00'))
material_cost = fabric_used * price_per_unit
salary_cutter_per_unit = to_decimal(config.get('salary_cutter_per_unit', '0.00'))
cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter_per_unit
# Создание записи о задании на раскрой
creation_time = get_current_time().isoformat()
cutting_task = {
'id': uuid.uuid4().hex,
'fabric_id': fabric_id,
'fabric_name': fabric_material['name'], # Сохраняем имя для удобства
'fabric_unit': fabric_material['unit'], # Сохраняем единицу измерения
'cut_items_quantity': cut_items_quantity, # int
'fabric_used': str(fabric_used), # Сохраняем как строку
'status': 'pending', # Начальный статус - ожидает пошива
'timestamp_created': creation_time,
'timestamp_completed': None, # Время завершения (когда начнется пошив)
'material_cost': str(material_cost), # Стоимость израсходованной ткани
'cutting_salary_cost': str(cutting_salary_cost) # Стоимость работы раскройщика
}
# Обновление остатка ткани
new_available_quantity = available_quantity - fabric_used
material_updated = False
current_materials = data.get('materials', [])
for i, mat in enumerate(current_materials):
if isinstance(mat, dict) and mat.get('id') == fabric_id:
# Обновляем количество и время последнего изменения
current_materials[i]['quantity'] = str(new_available_quantity.quantize(Decimal('0.01'))) # Округляем до 2 знаков
current_materials[i]['timestamp_last_updated'] = creation_time
material_updated = True
break
if not material_updated:
# Это не должно произойти, если find_item_by_id сработал, но на всякий случай
flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger")
return redirect(url_for('cutting'))
# Добавление задания в список и сохранение данных
if 'cutting_tasks' not in data: data['cutting_tasks'] = []
data['cutting_tasks'].append(cutting_task)
# data['materials'] уже содержит обновленный список материалов
save_data(data)
flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success")
upload_db_to_hf(DATA_FILE) # Бэкап
return redirect(url_for('cutting'))
except Exception as e:
logging.error(f"Ошибка при регистрации раскроя: {e}", exc_info=True)
flash(f"Произошла внутренняя ошибка при регистрации раскроя: {e}", "danger")
return redirect(url_for('cutting'))
# GET запрос: отображаем страницу
# Преобразуем данные о ткани для отображения
fabrics_display = []
for f in fabrics:
if isinstance(f, dict) and 'id' in f: # Доп. проверка
f_copy = find_item_by_id(f['id'], 'materials') # Получаем данные с преобразованными типами
if f_copy:
f_copy['quantity_str'] = format_currency_py(f_copy.get('quantity', '0.00')) # Форматируем для отображения
fabrics_display.append(f_copy)
html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Раскрой ткани").replace('__CONTENT__', CUTTING_CONTENT).replace('__SCRIPTS__', CUTTING_SCRIPTS)
return render_template_string(html, fabrics=fabrics_display)
# 3. Маршрут "Пошив"
@app.route('/sewing', methods=['GET', 'POST'])
def sewing():
data = load_data()
# Находим задания раскроя, ожидающие пошива
pending_cutting_tasks = []
for t in data.get('cutting_tasks', []):
if isinstance(t, dict) and t.get('status') == 'pending':
pending_cutting_tasks.append(t)
# Находим доступную фурнитуру
available_fittings = []
for m in data.get('materials', []):
if isinstance(m, dict) and m.get('type') == 'fittings':
# Преобразуем в int для фурнитуры перед сравнением
if int(to_decimal(m.get('quantity', '0'))) > 0:
available_fittings.append(m)
# Все материалы (для выбора брака)
all_materials = [m for m in data.get('materials', []) if isinstance(m, dict)]
config = data.get('config', {})
if request.method == 'POST':
try:
cutting_task_id = request.form.get('cutting_task_id')
sewn_product_name = request.form.get('sewn_product_name', '').strip()
sewn_quantity_str = request.form.get('sewn_quantity')
# Фурнитура
fitting_ids = request.form.getlist('fitting_ids[]')
fitting_quantities = request.form.getlist('fitting_quantities[]')
# Брак
defect_material_ids = request.form.getlist('defect_material_id[]')
defect_quantities = request.form.getlist('defect_quantity[]')
# Валидация основных полей
if not cutting_task_id or not sewn_product_name or not sewn_quantity_str:
flash("Необходимо выбрать задание на раскрой, указать название изделия и количество сшитых.", "danger")
return redirect(url_for('sewing'))
# Находим задание на раскрой
cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks')
if not cutting_task or cutting_task.get('status') != 'pending':
flash("Выбранное задание на раскрой не найдено или уже не находится в статусе 'Ожидает пошива'.", "danger")
return redirect(url_for('sewing'))
# Валидация количества сшитых
try:
sewn_quantity = int(to_decimal(sewn_quantity_str).to_integral_value())
# find_item_by_id уже вернул cut_items_quantity как int
cut_quantity = cutting_task.get('cut_items_quantity', 0)
if sewn_quantity <= 0: raise ValueError("Кол-во > 0")
if sewn_quantity > cut_quantity:
flash(f"Количество сшитых ({sewn_quantity}) не может превышать количество раскроенных ({cut_quantity}).", "danger")
return redirect(url_for('sewing'))
except (InvalidOperation, ValueError):
flash("Некорректное количество сшитых изделий. Введите целое положительное число.", "danger")
return redirect(url_for('sewing'))
# --- Обработка фурнитуры ---
fittings_consumed = []
fittings_total_cost = Decimal('0')
materials_to_update = {} # Словарь {material_id: quantity_to_deduct} для атомарного списания
sewing_time = get_current_time().isoformat()
is_valid = True
if fitting_ids and fitting_quantities and len(fitting_ids) == len(fitting_quantities):
for i in range(len(fitting_ids)):
fitting_id = fitting_ids[i]
quantity_str = fitting_quantities[i]
if not fitting_id or not quantity_str: continue # Пропускаем пустые строки фурнитуры
fitting_material = find_item_by_id(fitting_id, 'materials')
if not fitting_material or fitting_material.get('type') != 'fittings':
flash(f"Ошибка: Фурнитура с ID {fitting_id} не найдена или не является фурнитурой.", "danger")
is_valid = False; break
try:
# Количество фурнитуры вводится как целое число
quantity_used = int(to_decimal(quantity_str).to_integral_value())
if quantity_used <= 0: raise ValueError("Кол-во > 0")
except (InvalidOperation, ValueError):
flash(f"Некорректное количество для фурнитуры '{fitting_material['name']}'. Введите целое положительное число.", "danger")
is_valid = False; break
# Проверка доступности с учетом уже запланированного списания
# find_item_by_id вернул quantity как Decimal, преобразуем в int для фурнитуры
available_qty_int = int(fitting_material.get('quantity', Decimal('0')))
# materials_to_update хранит Decimal, преобразуем в int для сравнения
planned_deduction_int = int(materials_to_update.get(fitting_id, Decimal('0')))
if available_qty_int < planned_deduction_int + quantity_used:
flash(f"Недостаточно фурнитуры '{fitting_material['name']}'. "
f"В наличии: {format_integer_py(available_qty_int)}, "
f"уже запланировано списать: {format_integer_py(planned_deduction_int)}, "
f"требуется еще: {format_integer_py(quantity_used)}.", "danger")
is_valid = False; break
# Добавляем в план списания (используем Decimal для единообразия в словаре)
materials_to_update[fitting_id] = materials_to_update.get(fitting_id, Decimal('0')) + Decimal(quantity_used)
# Считаем стоимость фурнитуры
price = fitting_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal
cost = price * Decimal(quantity_used)
fittings_total_cost += cost
# Добавляем в список использованной фурнитуры для задачи пошива
fittings_consumed.append({
'fitting_id': fitting_id,
'fitting_name': fitting_material['name'],
'quantity_used': quantity_used, # Сохраняем int
'cost': str(cost) # Сохраняем строку
})
if not is_valid: return redirect(url_for('sewing'))
# --- Обработка брака ---
defects_reported = []
if defect_material_ids and defect_quantities and len(defect_material_ids) == len(defect_material_ids):
for i in range(len(defect_material_ids)):
material_id = defect_material_ids[i]
quantity_str = defect_quantities[i]
if not material_id or not quantity_str: continue # Пропускаем пустые строки брака
defect_material = find_item_by_id(material_id, 'materials')
if not defect_material:
flash(f"Предупреждение: Материал для брака с ID {material_id} не найден.", "warning")
continue # Пропускаем, но не останавливаем процесс
# Валидация количества брака в зависимости от типа
material_type = defect_material.get('type')
quantity_deduct = Decimal('0')
quantity_log_value = 0 # Значение для записи в лог (int или Decimal)
is_fabric = material_type == 'fabric'
try:
if is_fabric:
quantity_deduct = to_decimal(quantity_str)
if quantity_deduct <= 0: raise ValueError("Кол-во ткани > 0")
quantity_log_value = quantity_deduct # Decimal для лога
else: # fittings
quantity_int = int(to_decimal(quantity_str).to_integral_value())
if quantity_int <= 0: raise ValueError("Кол-во фурн. > 0")
quantity_deduct = Decimal(quantity_int)
quantity_log_value = quantity_int # int для лога
except (InvalidOperation, ValueError):
flash(f"Некорректное количество брака для '{defect_material['name']}'.", "warning")
continue # Пропускаем эту запись брака
# Проверка доступности с учетом уже запланированного
available_qty = defect_material.get('quantity', Decimal('0')) # Уже Decimal
planned_deduction = materials_to_update.get(material_id, Decimal('0'))
effective_available = available_qty - planned_deduction
if effective_available < quantity_deduct:
available_str = format_currency_py(effective_available) if is_fabric else format_integer_py(effective_available)
deduct_str = format_currency_py(quantity_deduct) if is_fabric else format_integer_py(quantity_deduct)
flash(f"Недостаточно '{defect_material['name']}' для списания в брак ({deduct_str} {defect_material['unit']}). "
f"Доступно с учетом других списаний: {available_str} {defect_material['unit']}.", "danger")
is_valid = False; break
# Добавляем в план списания
materials_to_update[material_id] = planned_deduction + quantity_deduct
# Считаем стоимость брака
price = defect_material.get('price_per_unit', Decimal('0.00')) # Уже Decimal
defect_cost = price * quantity_deduct
# Готовим запись для лога брака
defect_entry = {
'log_id': uuid.uuid4().hex,
'material_id': material_id,
'material_name': defect_material['name'],
'quantity': str(quantity_log_value) if is_fabric else int(quantity_log_value), # Строка Decimal или int
'unit': defect_material['unit'],
'type': material_type,
'stage': 'sewing', # Этап, на котором обнаружен брак
'reason': 'Брак при пошиве', # Можно сделать поле в форме
'cost': str(defect_cost), # Сохраняем строку
'sewing_task_id': None, # Будет установлен после создания задачи пошива
'timestamp': sewing_time
}
defects_reported.append(defect_entry)
if not is_valid: return redirect(url_for('sewing'))
# --- Списание материалов (фурнитура + брак) ---
current_materials = data.get('materials', [])
for material_id, quantity_to_deduct in materials_to_update.items():
material_updated = False
for i, mat in enumerate(current_materials):
if isinstance(mat, dict) and mat.get('id') == material_id:
current_qty = to_decimal(mat.get('quantity', '0'))
new_qty = current_qty - quantity_to_deduct
# Округление в зависимости от типа
if mat.get('type') == 'fabric':
new_qty = new_qty.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
else: # fittings
new_qty = new_qty.quantize(Decimal('0'), rounding=ROUND_HALF_UP) # Округляем до целого (0 знаков)
# Убедимся, что не ушли в минус
if new_qty < 0: new_qty = Decimal('0')
current_materials[i]['quantity'] = str(new_qty)
current_materials[i]['timestamp_last_updated'] = sewing_time
material_updated = True
logging.info(f"Списан материал ID {material_id}: {format_currency_py(quantity_to_deduct) if mat.get('type') == 'fabric' else format_integer_py(quantity_to_deduct)} {mat.get('unit')}. Новый остаток: {format_currency_py(new_qty) if mat.get('type') == 'fabric' else format_integer_py(new_qty)}")
break
if not material_updated:
# Этого не должно произойти, если проверки выше сработали
flash(f"Критическая ошибка: Не удалось списать материал с ID {material_id}.", "danger")
return redirect(url_for('sewing'))
# --- Расчет зарплаты швеи ---
salary_sewer_per_unit = to_decimal(config.get('salary_sewer_per_unit', '0.00'))
sewing_salary_cost = Decimal(sewn_quantity) * salary_sewer_per_unit
# --- Создание задачи пошива ---
sewing_task = {
'id': uuid.uuid4().hex,
'cutting_task_id': cutting_task_id,
'product_name': sewn_product_name,
'sewn_quantity': sewn_quantity, # int
'fabric_id': cutting_task['fabric_id'], # Для справки
'fabric_name': cutting_task['fabric_name'], # Для справки
'fittings_consumed': fittings_consumed, # Список использованной фурнитуры
'defects_reported': [], # Сюда добавим записи из defects_reported
'status': 'pending_qc', # Отправляем на ОТК
'timestamp_created': sewing_time,
'timestamp_completed': None, # Время завершения (после ОТК/упаковки)
'qc_packed_quantity': 0, # Количество, прошедшее ОТК
'qc_defective_quantity': 0, # Количество брака на этапе ОТК
'fittings_cost': str(fittings_total_cost), # Общая стоимость фурнитуры
'sewing_salary_cost': str(sewing_salary_cost), # ЗП швеи
# Переносим стоимость ЗП раскройщика из задачи раскроя (уже строка Decimal)
'cutting_salary_cost': cutting_task.get('cutting_salary_cost', '0.00')
}
# Привязываем ID задачи пошива к записям о браке и добавляем их в задачу
for defect in defects_reported:
defect['sewing_task_id'] = sewing_task['id']
sewing_task['defects_reported'].append(defect)
# --- Обновление статуса задачи раскроя ---
current_cutting_tasks = data.get('cutting_tasks', [])
cutting_task_updated = False
for i, task in enumerate(current_cutting_tasks):
if isinstance(task, dict) and task.get('id') == cutting_task_id and task.get('status') == 'pending':
current_cutting_tasks[i]['status'] = 'completed'
current_cutting_tasks[i]['timestamp_completed'] = sewing_time
cutting_task_updated = True
logging.info(f"Статус задания на раскрой {cutting_task_id} изменен на 'completed'.")
break
elif isinstance(task, dict) and task.get('id') == cutting_task_id:
# Задача найдена, но статус уже не pending (маловероятно из-за проверки выше)
cutting_task_updated = True # Считаем, что обработали, раз нашли
logging.warning(f"Попытка обновить статус для уже обработанного задания раскроя {cutting_task_id}.")
break
if not cutting_task_updated:
# Этого тоже не должно произойти
logging.error(f"Критическая ошибка: Не удалось найти и обновить статус задания на раскрой {cutting_task_id}.")
flash(f"Критическая ошибка при обновлении статуса задания раскроя {cutting_task_id}.", "danger")
# Решаем, откатывать ли транзакцию или продолжить с предупреждением
# Пока продолжим, но залогировали ошибку.
# --- Сохранение всех изменений ---
if 'sewing_tasks' not in data: data['sewing_tasks'] = []
data['sewing_tasks'].append(sewing_task)
# Добавляем брак в общий лог брака, если он был
if defects_reported:
if 'defect_log' not in data: data['defect_log'] = []
data['defect_log'].extend(sewing_task['defects_reported']) # Добавляем те же объекты
# data['materials'] и data['cutting_tasks'] уже обновлены
save_data(data)
flash(f"Пошив {sewn_quantity} ед. '{sewn_product_name}' успешно зарегистрирован. Статус: Ожидает ОТК.", "success")
if fittings_consumed:
flash(f"Использовано {len(fittings_consumed)} позиций фурнитуры на сумму {format_currency_py(fittings_total_cost)} сом.", "info")
if defects_reported:
flash(f"Зарегистрировано {len(defects_reported)} позиций брака.", "warning")
upload_db_to_hf(DATA_FILE) # Бэкап
return redirect(url_for('sewing'))
except Exception as e:
logging.error(f"Ошибка при регистрации пошива: {e}", exc_info=True)
flash(f"Произошла внутренняя ошибка при регистрации пошива: {e}", "danger")
return redirect(url_for('sewing'))
# GET запрос: отображаем страницу
# Готовим данные для шаблона
tasks_for_template = []
for task in pending_cutting_tasks:
if isinstance(task, dict) and 'id' in task:
task_data = find_item_by_id(task['id'], 'cutting_tasks')
if task_data:
task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00'))
tasks_for_template.append(task_data)
fittings_for_template = []
for f in available_fittings:
if isinstance(f, dict) and 'id' in f:
f_data = find_item_by_id(f['id'], 'materials')
if f_data:
# Фурнитура имеет целое количество
f_data['quantity_str'] = format_integer_py(f_data.get('quantity', '0'))
fittings_for_template.append(f_data)
all_materials_for_template = []
for m in all_materials:
if isinstance(m, dict) and 'id' in m:
m_data = find_item_by_id(m['id'], 'materials')
if m_data:
if m_data.get('type') == 'fabric':
m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00'))
else:
m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0'))
all_materials_for_template.append(m_data)
html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "Пошив изделий").replace('__CONTENT__', SEWING_CONTENT).replace('__SCRIPTS__', SEWING_SCRIPTS)
return render_template_string(html, cutting_tasks=tasks_for_template, fittings=fittings_for_template, all_materials=all_materials_for_template)
# 4. Маршрут "ОТК и Упаковка"
@app.route('/qc_packing', methods=['GET', 'POST'])
def qc_packing():
data = load_data()
# Находим задания пошива, ожидающие ОТК
pending_qc_tasks = []
for t in data.get('sewing_tasks', []):
if isinstance(t, dict) and t.get('status') == 'pending_qc':
pending_qc_tasks.append(t)
config = data.get('config', {})
if request.method == 'POST':
try:
sewing_task_id = request.form.get('sewing_task_id')
quantity_packed_str = request.form.get('quantity_packed')
quantity_defective_str = request.form.get('quantity_defective', '0') # По умолчанию 0
defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip()
# Валидация выбора задания
if not sewing_task_id:
flash("Необходимо выбрать задание на пошив.", "danger")
return redirect(url_for('qc_packing'))
# Находим задание пошива
sewing_task = find_item_by_id(sewing_task_id, 'sewing_tasks')
if not sewing_task or sewing_task.get('status') != 'pending_qc':
flash("Выбранное задание на пошив не найдено или уже не ожидает ОТК.", "danger")
return redirect(url_for('qc_packing'))
# Валидация количества
try:
quantity_packed = int(to_decimal(quantity_packed_str).to_integral_value()) if quantity_packed_str else 0
quantity_defective = int(to_decimal(quantity_defective_str).to_integral_value()) if quantity_defective_str else 0
if quantity_packed < 0 or quantity_defective < 0:
raise ValueError("Количество не может быть отрицательным")
total_processed_now = quantity_packed + quantity_defective
if total_processed_now <= 0:
flash("Необходимо указать количество упакованных или бракованных изделий (сумма должна быть > 0).", "warning")
return redirect(url_for('qc_packing'))
# Рассчитываем, сколько осталось обработать по этому заданию
# find_item_by_id уже вернул int для этих полей
total_sewn = sewing_task.get('sewn_quantity', 0)
already_packed = sewing_task.get('qc_packed_quantity', 0)
already_defective = sewing_task.get('qc_defective_quantity', 0)
remaining_to_process = total_sewn - (already_packed + already_defective)
if total_processed_now > remaining_to_process:
flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed_now}, "
f"что превышает остаток изделий для обработки ({remaining_to_process}).", "danger")
return redirect(url_for('qc_packing'))
except (InvalidOperation, ValueError) as e:
flash(f"Некорректное количество упакованных или бракованных изделий: {e}", "danger")
return redirect(url_for('qc_packing'))
qc_time = get_current_time().isoformat()
new_packed_item_entry = None # Запись для qc_packing_items
new_defect_log_entry = None # Запись для defect_log
# --- Обработка упакованных (прошедших ОТК) ---
if quantity_packed > 0:
# Получаем связанные данные для расчета себестоимости
cutting_task_id = sewing_task.get('cutting_task_id')
# Используем find_item_by_id, который вернет данные с Decimal/int
cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks')
# Если задание на раскрой не найдено, используем нули
if not cutting_task:
logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости для пошива {sewing_task_id}.")
cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1}
# Получаем стоимости из задач (уже в Decimal)
fabric_cost_total = cutting_task.get('material_cost', Decimal('0'))
cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0'))
# --- ИЗМЕНЕНО: получаем стоимость фурнитуры из задачи пошива ---
fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0'))
sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0'))
# Получаем количество из задач для расчета на единицу (уже int)
cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 # Избегаем деления на ноль
sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 # Избегаем деления на ноль
# Расчет себестоимости на 1 изделие
fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty)
# --- ИЗМЕНЕНО: добавляем расчет стоимости фурнитуры на 1 изделие ---
fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task)
material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item # Общая стоимость материалов
cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty)
sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task)
packing_salary_per_item = to_decimal(config.get('salary_packer_per_unit', '0.00'))
salary_cost_per_item = cutting_salary_per_item + sewing_salary_per_item + packing_salary_per_item # Общая стоимость ЗП
total_cost_per_item = material_cost_per_item + salary_cost_per_item # Полная себестоимость 1 ед.
# Расчет цены продажи на 1 изделие
margin_per_item = to_decimal(config.get('margin_per_item', '0.00'))
final_price_per_item = total_cost_per_item + margin_per_item
# Расчет общих сумм для текущей пачки упакованных
packed_batch_material_cost = material_cost_per_item * Decimal(quantity_packed)
packed_batch_salary_cost = salary_cost_per_item * Decimal(quantity_packed)
packed_batch_total_cost = total_cost_per_item * Decimal(quantity_packed)
packed_batch_margin = margin_per_item * Decimal(quantity_packed)
packed_batch_final_price = final_price_per_item * Decimal(quantity_packed)
# Создаем запись об упакованном товаре
new_packed_item_entry = {
'id': uuid.uuid4().hex,
'sewing_task_id': sewing_task_id,
'product_name': sewing_task['product_name'],
'quantity': quantity_packed, # int - количество в ЭТОЙ партии
'timestamp_packed': qc_time,
'packed_material_cost': str(packed_batch_material_cost),
'packed_salary_cost': str(packed_batch_salary_cost),
'packed_total_cost': str(packed_batch_total_cost), # Себестоимость этой партии
'packed_margin': str(packed_batch_margin), # Маржа этой партии
'packed_final_price': str(packed_batch_final_price), # Цена продажи этой партии
'status': 'packed_ready_to_ship', # Готово к отправке
'shipment_details': None # Детали отправки будут добавлены позже
}
if 'qc_packing_items' not in data: data['qc_packing_items'] = []
data['qc_packing_items'].append(new_packed_item_entry)
# --- Обработка брака на этапе ОТК ---
if quantity_defective > 0:
# Рассчитываем себестоимость бракованного изделия (без ЗП упаковщика и без маржи)
cutting_task_id = sewing_task.get('cutting_task_id')
cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks')
if not cutting_task:
logging.warning(f"Не найдено задание на раскрой {cutting_task_id} при расчете себестоимости брака ОТК для {sewing_task_id}.")
cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1}
# Стоимости и кол-во уже в нужных типах из find_item_by_id
fabric_cost_total = cutting_task.get('material_cost', Decimal('0'))
cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0'))
# --- ИЗМЕНЕНО: получаем стоимость фурнитуры из задачи пошива ---
fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0'))
sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0'))
cut_qty = cutting_task.get('cut_items_quantity', 1) or 1
sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1
fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty)
# --- ИЗМЕНЕНО: добавляем стоимость фурнитуры ---
fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task)
material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item
cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty)
sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task)
salary_cost_per_defective_item = cutting_salary_per_item + sewing_salary_per_item # ЗП упаковщика не учитываем
# Себестоимость 1 бракованного изделия
cost_per_defective_item = material_cost_per_item + salary_cost_per_defective_item
# Общая стоимость списания брака
total_defect_cost = cost_per_defective_item * Decimal(quantity_defective)
# Создаем запись для лога брака
new_defect_log_entry = {
'log_id': uuid.uuid4().hex,
'material_id': None, # Брак не конкретного материала, а готового изделия
'material_name': f"{sewing_task['product_name']} (готовое изделие)",
'quantity': quantity_defective, # int
'unit': 'шт',
'type': 'finished_product', # Тип брака - готовое изделие
'stage': 'qc_packing', # Этап обнаружения
'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке',
'cost': str(total_defect_cost), # Общая стоимость списания
'sewing_task_id': sewing_task_id, # Ссылка на задачу пошива
'timestamp': qc_time
}
if 'defect_log' not in data: data['defect_log'] = []
data['defect_log'].append(new_defect_log_entry)
logging.info(f"Зарегистрирован брак ОТК: {quantity_defective} ед. '{sewing_task['product_name']}' (Общая стоимость: {format_currency_py(total_defect_cost)})")
# --- Обновление задачи пошива ---
sewing_task_updated = False
current_sewing_tasks = data.get('sewing_tasks', [])
for i, task in enumerate(current_sewing_tasks):
if isinstance(task, dict) and task.get('id') == sewing_task_id:
# Увеличиваем счетчики обработанных
current_sewing_tasks[i]['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) + quantity_packed
current_sewing_tasks[i]['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) + quantity_defective
# Проверяем, завершена ли обработка всего задания
total_processed_for_task = current_sewing_tasks[i]['qc_packed_quantity'] + current_sewing_tasks[i]['qc_defective_quantity']
if total_processed_for_task >= int(task.get('sewn_quantity', 0)):
# Если все обработано, меняем статус и ставим время завершения
current_sewing_tasks[i]['status'] = 'completed'
current_sewing_tasks[i]['timestamp_completed'] = qc_time
logging.info(f"Задание на пошив {sewing_task_id} полностью обработано и завершено.")
else:
# Если обработано частично, статус остается pending_qc
current_sewing_tasks[i]['status'] = 'pending_qc' # Явно оставляем статус
logging.info(f"Задание на пошив {sewing_task_id} обработано частично. Осталось: {remaining_to_process - total_processed_now}")
sewing_task_updated = True
break
if not sewing_task_updated:
logging.error(f"Критическая ошибка: Не удалось найти и обновить задание на пошив {sewing_task_id}.")
# Возможно, стоит откатить изменения или выдать более серьезное предупреждение
flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger")
# --- Сохранение данных ---
# data['sewing_tasks'], data['qc_packing_items'], data['defect_log'] уже обновлены
save_data(data)
flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. "
if new_packed_item_entry:
flash_message += f"Статус упакованных: Готово к отправке."
elif new_defect_log_entry:
flash_message += f"Брак зарегистрирован."
flash(flash_message, "success")
upload_db_to_hf(DATA_FILE) # Бэкап
return redirect(url_for('qc_packing'))
except Exception as e:
logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True)
flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger")
return redirect(url_for('qc_packing'))
# GET запрос: отображаем страницу
# Готовим список задач для шаблона, рассчитывая остаток
tasks_for_template = []
for task in pending_qc_tasks:
if isinstance(task, dict) and 'id' in task:
task_data = find_item_by_id(task['id'], 'sewing_tasks')
if task_data:
total_sewn = task_data.get('sewn_quantity', 0)
already_processed = task_data.get('qc_packed_quantity', 0) + task_data.get('qc_defective_quantity', 0)
remaining = total_sewn - already_processed
if remaining > 0: # Показываем только те, где еще есть что обрабатывать
task_data['remaining_quantity'] = remaining
tasks_for_template.append(task_data)
html = BASE_TEMPLATE_OPERATIONAL.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS)
return render_template_string(html, sewing_tasks=tasks_for_template)
# 5. Маршрут "База клиентов"
@app.route('/clients', methods=['GET', 'POST'])
def clients_panel():
if request.method == 'POST':
# Добавление нового клиента
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'))
# --- GET запрос: отображение списка клиентов ---
try:
clients_data = load_client_data() # Загружаем проверенные данные
clients_data.sort(key=lambda x: x.get('name','').lower()) # Сортировка по имени
# Подготовка данных для шаблона: обработка истории
# load_client_data УЖЕ гарантирует, что history и items являются списками
for client in clients_data:
# Сортировка истории (теперь безопасно) и парсинг дат
if 'history' in client: # Проверка типа уже не нужна, т.к. load_client_data ее сделал
# Сортируем исходный список (не создаем копию для сортировки тут)
client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True)
# Добавляем datetime объекты для удобного отображения в шаблоне
for record in client['history']: # record - точно словарь
record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp'))
# Проверка record['items'] уже не нужна
html = BASE_TEMPLATE.replace('__TITLE__', "База клиентов").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS)
# Передаем обработанные данные в шаблон
return render_template_string(html, clients=clients_data)
except Exception as e:
logging.error(f"Неожиданная ошибка в GET /clients: {e}", exc_info=True)
flash("Произошла ошибка при отображении страницы клиентов.", "danger")
# Можно перенаправить на главную или показать пустую страницу
return redirect(url_for('admin_panel'))
# 6. Маршрут "Админ-панель"
@app.route('/admin')
def admin_panel():
data = load_data()
clients_data = load_client_data() # Загружаем проверенных клиентов
config = data.get('config', {})
# Получаем актуальные данные с помощью find_item_by_id для всех списков
# Фильтруем материалы с количеством > 0 прямо здесь
all_materials = [m for m_id in [m.get('id') for m in data.get('materials', []) if isinstance(m, dict)]
if (m := find_item_by_id(m_id, 'materials')) is not None and m.get('quantity', Decimal('0')) > 0]
materials_count = len(all_materials) # Обновляем счетчик после фильтрации
all_cutting_tasks = [t for t_id in [t.get('id') for t in data.get('cutting_tasks', []) if isinstance(t, dict)] if (t := find_item_by_id(t_id, 'cutting_tasks')) is not None]
all_sewing_tasks = [s for s_id in [s.get('id') for s in data.get('sewing_tasks', []) if isinstance(s, dict)] if (s := find_item_by_id(s_id, 'sewing_tasks')) is not None]
all_packed_items = [p for p_id in [p.get('id') for p in data.get('qc_packing_items', []) if isinstance(p, dict)] if (p := find_item_by_id(p_id, 'qc_packing_items')) is not None]
all_defect_log = [d for d_id in [d.get('log_id') for d in data.get('defect_log', []) if isinstance(d, dict)] if (d := find_item_by_id(d_id, 'defect_log')) is not None]
all_expenses = [e for e_id in [e.get('id') for e in data.get('expenses', []) if isinstance(e, dict)] if (e := find_item_by_id(e_id, 'expenses')) is not None]
# Получаем историю отправок на Дордой
dordoi_shipments = data.get('dordoi_shipments', [])
for ship in dordoi_shipments: # Парсим даты
ship['timestamp_dt'] = parse_iso_datetime(ship.get('timestamp'))
dordoi_shipments.sort(key=lambda x: x.get('timestamp',''), reverse=True) # Сортируем
categories = data.get('categories', [])
# --- Расчет сводных данных ---
items_ready_to_ship = [item for item in all_packed_items if item.get('status') == 'packed_ready_to_ship']
items_ready_ship_count = len(items_ready_to_ship)
# ИЗМЕНЕНО: Суммируем количество только из готовых к отправке
items_ready_ship_qty = sum(item.get('quantity', 0) for item in items_ready_to_ship)
# Счетчик "Упаковано Всего" теперь считает только готовые к отправке (items_ready_ship_qty)
total_packed_count = items_ready_ship_qty
# Остальные счетчики
pending_cutting_count = len([task for task in all_cutting_tasks if task.get('status') == 'pending'])
pending_qc_tasks = [task for task in all_sewing_tasks if task.get('status') == 'pending_qc']
pending_qc_count = len(pending_qc_tasks)
pending_qc_quantity = sum(task.get('sewn_quantity', 0) - (task.get('qc_packed_quantity', 0) + task.get('qc_defective_quantity', 0)) for task in pending_qc_tasks) # Считаем остаток
total_defect_fabric_m = sum(d.get('quantity_raw', Decimal('0')) for d in all_defect_log if d.get('type') == 'fabric')
total_defect_fittings_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'fittings')
total_defect_finished_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'finished_product')
total_defect_cost = sum(d.get('cost_dec', Decimal('0')) for d in all_defect_log)
config_decimal = {k: to_decimal(v) for k, v in config.items()}
# Передача данных в шаблон
html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', ADMIN_SCRIPTS)
return render_template_string(html,
materials=all_materials, # Уже отфильтрованные
cutting_tasks=all_cutting_tasks,
sewing_tasks=all_sewing_tasks,
packed_items=all_packed_items,
items_ready_to_ship=items_ready_to_ship,
clients=sorted(clients_data, key=lambda x: x.get('name','').lower()),
defect_log=all_defect_log,
expenses=all_expenses,
dordoi_shipments=dordoi_shipments,
categories=categories,
config=config_decimal,
materials_count=materials_count, # Счетчик отфильтрованных
pending_cutting_count=pending_cutting_count,
pending_qc_count=pending_qc_count,
pending_qc_quantity=pending_qc_quantity, # Теперь это остаток
total_packed_count=total_packed_count, # Теперь считает только готовые к отправке
items_ready_ship_count=items_ready_ship_count,
items_ready_ship_qty=items_ready_ship_qty,
total_defect_fabric_m=format_currency_py(total_defect_fabric_m),
total_defect_fittings_pcs=format_integer_py(total_defect_fittings_pcs),
total_defect_finished_pcs=format_integer_py(total_defect_finished_pcs),
total_defect_cost=format_currency_py(total_defect_cost)
)
# 7. Маршрут для выполнения отправки (с частичной отправкой)
@app.route('/dispatch_item', methods=['POST'])
def dispatch_item():
item_id = request.form.get('item_id')
destination_type = request.form.get('destination_type')
client_id = request.form.get('client_id') # Может быть None
quantity_to_dispatch_str = request.form.get('quantity_to_dispatch') # Новое поле
redirect_target = url_for('admin_panel') + '#dispatch-content'
if not item_id or not destination_type or not quantity_to_dispatch_str:
flash("Ошибка: Не указан ID товара, тип назначения или количество для отправки.", "danger")
return redirect(redirect_target)
data = load_data()
clients = load_client_data()
packed_item_to_update = None
item_index = -1
packed_items_list = data.get('qc_packing_items', [])
for i, item in enumerate(packed_items_list):
if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship':
packed_item_to_update = item # Работаем с оригинальным словарем
item_index = i
break
if not packed_item_to_update:
flash(f"Ошибка: Товар с ID {item_id}, готовый к отправке, не найден.", "danger")
return redirect(redirect_target)
# Валидация количества
try:
quantity_to_dispatch = int(to_decimal(quantity_to_dispatch_str).to_integral_value())
current_quantity = int(to_decimal(packed_item_to_update.get('quantity', '0')))
if quantity_to_dispatch <= 0:
raise ValueError("Количество должно быть положительным")
if quantity_to_dispatch > current_quantity:
flash(f"Ошибка: Нельзя отправить {quantity_to_dispatch} шт., так как в наличии только {current_quantity} шт.", "danger")
return redirect(redirect_target)
except (InvalidOperation, ValueError) as e:
flash(f"Некорректное количество для отправки: {e}", "danger")
return redirect(redirect_target)
dispatch_time_iso = get_current_time().isoformat()
client_data_changed = False
main_data_changed = False
product_name = packed_item_to_update.get('product_name', 'N/A')
destination_display_text = ''
# Определяем, полная или частичная отправка
is_full_dispatch = (quantity_to_dispatch == current_quantity)
# --- Общие действия для обоих типов отправки ---
history_items = [{'product_name': product_name, 'quantity': quantity_to_dispatch}] # Предметы для записи в историю
if destination_type == 'client':
if not client_id:
flash("Ошибка: Не выбран клиент для отправки.", "danger")
return redirect(redirect_target)
client_object_to_update = None
client_name = "Клиент не найден"
for cl in clients:
if cl.get('id') == client_id:
client_object_to_update = cl
client_name = cl.get('name', 'Имя не найдено')
break
if not client_object_to_update:
flash(f"Ошибка: Клиент с ID {client_id} не найден в базе.", "danger")
return redirect(redirect_target)
# Запись в историю клиента
history_entry = {
'shipment_id': uuid.uuid4().hex,
'timestamp': dispatch_time_iso,
'items': history_items,
'packed_item_id': item_id # Ссылка на исходную упаковку
}
if not isinstance(client_object_to_update.get('history'), list):
client_object_to_update['history'] = []
client_object_to_update['history'].append(history_entry)
client_data_changed = True
destination_display_text = f"клиенту '{client_name}'"
# Обновление основной записи (если полная отправка)
if is_full_dispatch:
packed_item_to_update['status'] = 'shipped_client'
packed_item_to_update['shipment_details'] = {
'type': destination_type,
'timestamp': dispatch_time_iso,
'client_id': client_id,
'client_name': client_name
}
main_data_changed = True
elif destination_type == 'dor_doi_point':
# Запись в историю Дордоя
dordoi_entry = {
'shipment_id': uuid.uuid4().hex,
'timestamp': dispatch_time_iso,
'items': history_items,
'packed_item_id': item_id
}
if 'dordoi_shipments' not in data or not isinstance(data['dordoi_shipments'], list):
data['dordoi_shipments'] = []
data['dordoi_shipments'].append(dordoi_entry)
destination_display_text = "на Торговую точку Дордой"
main_data_changed = True # Так как dordoi_shipments в основном файле
# Обновление основной записи (если полная отправка)
if is_full_dispatch:
packed_item_to_update['status'] = 'shipped_dor_doi'
packed_item_to_update['shipment_details'] = {
'type': destination_type,
'timestamp': dispatch_time_iso,
'destination': 'Торговая точка Дордой'
}
# main_data_changed уже True
else:
flash("Ошибка: Неверный тип назначения.", "danger")
return redirect(redirect_target)
# --- Обработка частичной отправки (если не полная) ---
if not is_full_dispatch:
remaining_quantity = current_quantity - quantity_to_dispatch
proportion_remaining = Decimal(remaining_quantity) / Decimal(current_quantity)
# Пересчет стоимостей для оставшейся части
fields_to_recalculate = [
'packed_material_cost', 'packed_salary_cost',
'packed_total_cost', 'packed_margin', 'packed_final_price'
]
for field in fields_to_recalculate:
current_cost = to_decimal(packed_item_to_update.get(field, '0'))
remaining_cost = (current_cost * proportion_remaining).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
packed_item_to_update[field] = str(remaining_cost)
# Обновление количества
packed_item_to_update['quantity'] = remaining_quantity
# Статус остается 'packed_ready_to_ship'
# shipment_details не добавляется в основной элемент при частичной отправке
main_data_changed = True
logging.info(f"Частичная отправка товара {item_id} ({product_name}): {quantity_to_dispatch} шт. Осталось: {remaining_quantity} шт.")
# --- Сохранение изменений ---
if main_data_changed:
# data['qc_packing_items'][item_index] уже обновлен (так как packed_item_to_update - ссылка)
save_data(data)
upload_db_to_hf(DATA_FILE)
logging.info(f"Основные данные сохранены после отправки товара {item_id}.")
if client_data_changed:
save_client_data(clients)
upload_db_to_hf(CLIENT_DATA_FILE)
logging.info(f"Данные клиента {client_id} сохранены.")
flash(f"{quantity_to_dispatch} шт. товара '{product_name}' успешно отправлено {destination_display_text}.", "success")
return redirect(redirect_target)
# --- Маршрут для работы с авансами ---
@app.route('/advances', methods=['GET', 'POST'])
def advances():
data = load_data()
if 'advances' not in data:
data['advances'] = []
if 'monthly_salaries' not in data:
data['monthly_salaries'] = {}
if request.method == 'POST':
try:
employee_name = request.form.get('employee_name', '').strip()
role = request.form.get('role', '').strip()
amount_str = request.form.get('amount', '0')
month_year = get_current_time().strftime('%Y-%m')
# Initialize monthly salary tracking if not exists
if month_year not in data['monthly_salaries']:
data['monthly_salaries'][month_year] = {}
if employee_name not in data['monthly_salaries'][month_year]:
data['monthly_salaries'][month_year][employee_name] = {
'role': role,
'earned': Decimal('0'),
'advances': Decimal('0'),
'final_payout': Decimal('0')
}
if not employee_name or not role or not amount_str:
flash("Заполните все поля", "danger")
return redirect(url_for('advances'))
amount = to_decimal(amount_str)
if amount <= 0:
flash("Сумма аванса должна быть положительной", "danger")
return redirect(url_for('advances'))
advance = {
'id': uuid.uuid4().hex,
'employee_name': employee_name,
'role': role,
'amount': str(amount),
'timestamp': get_current_time().isoformat(),
'is_processed': False # Для отметки о вычете из ЗП
}
data['advances'].append(advance)
save_data(data)
flash(f"Аванс {format_currency_py(amount)} сом выдан {employee_name}", "success")
upload_db_to_hf(DATA_FILE)
except Exception as e:
logging.error(f"Ошибка при выдаче аванса: {e}", exc_info=True)
flash(f"Ошибка при выдаче аванса: {e}", "danger")
advances = data.get('advances', [])
advances.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Авансы").replace('__CONTENT__', ADVANCES_CONTENT).replace('__SCRIPTS__', ADVANCES_SCRIPTS)
return render_template_string(html, advances=advances)
# --- Остальные маршруты админ-панели ---
@app.route('/admin/config/update', methods=['POST'])
def update_config():
data = load_data()
config = data.get('config', {}) # Получаем текущую конфигурацию или пустой словарь
try:
# Получаем значения из формы, преобразуем в Decimal, затем в строку
config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter')))
config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer')))
config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer')))
config['margin_per_item'] = str(to_decimal(request.form.get('margin')))
data['config'] = config # Обновляем конфиг в основных данных
save_data(data)
flash("Настройки зарплат и маржи успешно сохранены.", "success")
upload_db_to_hf(DATA_FILE) # Бэкап
except InvalidOperation:
flash("Ошибка: Введено некорректное числовое значение.", "danger")
except Exception as e:
logging.error(f"Ошибка при обновлении конфигурации: {e}", exc_info=True)
flash(f"Произошла ошибка при сохранении настроек: {e}", "danger")
# Возвращаемся на админ-панель (вкладка настроек не указана, будет по умолчанию)
return redirect(url_for('admin_panel'))
@app.route('/admin/expense/add', methods=['POST'])
def add_expense():
data = load_data()
description = request.form.get('expense_description','').strip()
amount_str = request.form.get('expense_amount')
redirect_target = url_for('admin_panel') + '#expenses-report-content' # Вкладка отчета с расходами
if not description or not amount_str:
flash("Необходимо заполнить описание и сумму расхода.", "warning")
return redirect(redirect_target)
try:
amount = to_decimal(amount_str)
if amount <= 0: raise ValueError("Сумма должна быть > 0")
except (InvalidOperation, ValueError):
flash("Некорректное значение суммы расхода. Введите положительное число.", "warning")
return redirect(redirect_target)
if 'expenses' not in data or not isinstance(data['expenses'], list):
data['expenses'] = [] # Инициализируем, если отсутствует или не список
new_expense = {
'id': uuid.uuid4().hex,
'description': description,
'amount': str(amount), # Сохраняем как строку
'timestamp': get_current_time().isoformat()
}
data['expenses'].append(new_expense)
save_data(data)
flash(f"Расход '{description}' на сумму {format_currency_py(amount)} сом успешно добавлен.", "success")
upload_db_to_hf(DATA_FILE) # Бэкап
return redirect(redirect_target)
@app.route('/admin/category/add', methods=['POST'])
def add_category():
data = load_data()
categories = data.get('categories', [])
new_category_name = request.form.get('new_category_name','').strip()
if not new_category_name:
flash("Название категории не может быть пустым.", "warning")
return redirect(url_for('admin_panel')) # Возврат на админку
# Фильтруем существующие категории, оставляем только строки
current_valid_categories = [c for c in categories if isinstance(c, str)]
# Проверка на существование (без учета регистра)
if new_category_name.lower() not in [c.lower() for c in current_valid_categories]:
current_valid_categories.append(new_category_name)
# Обновляем список категорий в данных и сортируем
data['categories'] = sorted(list(set(current_valid_categories)), key=str.lower)
save_data(data)
flash(f"Категория '{new_category_name}' успешно добавлена.", "success")
upload_db_to_hf(DATA_FILE) # Бэкап
else:
flash(f"Категория '{new_category_name}' уже существует.", "warning")
return redirect(url_for('admin_panel'))
@app.route('/admin/category/delete', methods=['POST'])
def delete_category():
data = load_data()
categories = data.get('categories', [])
category_to_delete = request.form.get('category_to_delete') # Имя категории для удаления
if not category_to_delete:
flash("Не выбрана категория для удаления.", "warning")
return redirect(url_for('admin_panel'))
if category_to_delete == 'Без категории':
flash("Нельзя удалить системную категорию 'Без категории'.", "danger")
return redirect(url_for('admin_panel'))
# Находим точное имя категории (с учетом регистра) для удаления
original_category_name = None
category_found = False
current_valid_categories = [c for c in categories if isinstance(c, str)] # Работаем только со строками
for cat in current_valid_categories:
if cat.lower() == category_to_delete.lower():
original_category_name = cat
category_found = True
break
if category_found and original_category_name:
current_valid_categories.remove(original_category_name) # Удаляем из списка строк
data['categories'] = sorted(current_valid_categories, key=str.lower) # Сохраняем отсортированный список строк
# Обновляем материалы, которые принадлежали этой категории
materials_updated_count = 0
current_materials = data.get('materials', [])
update_time = get_current_time().isoformat()
for mat in current_materials:
if isinstance(mat, dict) and mat.get('category', 'Без категории') == original_category_name:
mat['category'] = 'Без категории' # Переносим в базовую категорию
mat['timestamp_last_updated'] = update_time
materials_updated_count += 1
# data['materials'] уже обновлен, если были изменения
save_data(data)
flash(f"Категория '{original_category_name}' успешно удалена.", "success")
if materials_updated_count > 0:
flash(f"{materials_updated_count} материалов были перенесены в категорию 'Без категории'.", "info")
upload_db_to_hf(DATA_FILE) # Бэкап
else:
flash(f"Категория '{category_to_delete}' не найдена.", "warning")
return redirect(url_for('admin_panel'))
@app.route('/backup', methods=['POST'])
def backup_hf():
"""Инициирует ручное резервное копирование обоих файлов на Hugging Face."""
files_uploaded_count = 0
try:
logging.info("Запуск ручного резервного копирования на Hugging Face...")
# Бэкап основного файла данных
with data_lock: # Используем блокировку на всякий случай
if os.path.exists(DATA_FILE):
upload_db_to_hf(DATA_FILE)
files_uploaded_count += 1
else:
flash(f"Локальный файл '{DATA_FILE}' не найден для бэкапа.", "warning")
# Бэкап файла клиентов
with client_data_lock: # Используем блокировку
if os.path.exists(CLIENT_DATA_FILE):
upload_db_to_hf(CLIENT_DATA_FILE)
files_uploaded_count += 1
else:
flash(f"Локальный файл '{CLIENT_DATA_FILE}' не найден для бэкапа.", "warning")
if files_uploaded_count > 0:
flash(f"Резервное копирование {files_uploaded_count} файлов на Hugging Face инициировано.", "success")
else:
flash("Не найдено локальных файлов для инициации резервного копирования.", "warning")
except Exception as e:
logging.error(f"Ошибка во время ручного резервного копирования: {e}", exc_info=True)
flash(f"Произошла ошибка во время резервного копирования: {e}", "danger")
return redirect(url_for('admin_panel'))
@app.route('/download', methods=['GET'])
def download_hf():
"""Скачивает оба файла данных с Hugging Face, перезаписывая локальные."""
downloaded_files = []
errors = []
logging.info("Запуск скачивания данных с Hugging Face...")
# Скачивание основного файла данных
try:
logging.info(f"Попытка скачивания {DATA_FILE}...")
hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=".", local_dir_use_symlinks=False, force_download=True)
downloaded_files.append(DATA_FILE)
logging.info(f"{DATA_FILE} успешно скачан.")
except RepositoryNotFoundError:
msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face."
logging.error(msg)
errors.append(msg)
except HfHubHTTPError as e:
if e.response.status_code == 404:
msg = f"Файл '{DATA_FILE}' не найден в репозитории '{REPO_ID}'."
logging.warning(msg)
errors.append(msg)
else:
msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {DATA_FILE}: {e}"
logging.error(msg)
errors.append(msg)
except Exception as e:
msg = f"Неизвестная ошибка при скачивании {DATA_FILE}: {e}"
logging.error(msg, exc_info=True)
errors.append(msg)
# Скачивание файла клиентов (даже если первый файл не скачался)
try:
logging.info(f"Попытка скачивания {CLIENT_DATA_FILE}...")
hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=".", local_dir_use_symlinks=False, force_download=True)
downloaded_files.append(CLIENT_DATA_FILE)
logging.info(f"{CLIENT_DATA_FILE} успешно скачан.")
except RepositoryNotFoundError:
# Ошибка репозитория уже должна была быть залогирована выше
if not any(f"Репозиторий '{REPO_ID}' не найден" in err for err in errors):
msg = f"Репозиторий '{REPO_ID}' не найден на Hugging Face."
logging.error(msg)
errors.append(msg)
except HfHubHTTPError as e:
if e.response.status_code == 404:
msg = f"Файл '{CLIENT_DATA_FILE}' не найден в репозитории '{REPO_ID}'."
logging.warning(msg)
errors.append(msg)
else:
msg = f"Ошибка HTTP ({e.response.status_code}) при скачивании {CLIENT_DATA_FILE}: {e}"
logging.error(msg)
errors.append(msg)
except Exception as e:
msg = f"Неизвестная ошибка при скачивании {CLIENT_DATA_FILE}: {e}"
logging.error(msg, exc_info=True)
errors.append(msg)
# Вывод сообщений пользователю
if downloaded_files:
flash(f"Файлы ({', '.join(downloaded_files)}) успешно скачаны и перезаписаны локально.", "success")
if errors:
flash("Во время скачивания произошли ошибки: " + "; ".join(errors), "danger")
if not downloaded_files and not errors:
# Странная ситуация, возможно, нет файлов или другая проблема
flash("Не удалось инициировать скачивание файлов.", "warning")
# Перезагрузка данных в память после скачивания
try:
logging.info("Перезагрузка данных в память после скачивания...")
load_data()
load_client_data()
logging.info("Данные в памяти обновлены.")
except Exception as e:
logging.error(f"Ошибка при перезагрузке данных после скачивания: {e}", exc_info=True)
flash("Внимание: Файлы скачаны, но произошла ошибка при обновлении данных в приложении. Может потребоваться перезапуск.", "warning")
return redirect(url_for('admin_panel'))
# 8. Маршрут "Отчеты"
@app.route('/reports', methods=['GET'])
def reports():
# Загружаем основные данные и конфиг
data = load_data()
config = data.get('config', {})
now = get_current_time() # Текущее время в Бишкеке
# Получение параметров фильтрации из URL
filter_type = request.args.get('filter', 'month') # По умолчанию - текущий месяц
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
date_str = request.args.get('date')
month_str = request.args.get('month')
year_str = request.args.get('year')
start_date_dt = None
end_date_dt = None
try:
# --- Определение временного диапазона ---
if filter_type == 'custom' and start_date_str and end_date_str:
sd = datetime.strptime(start_date_str, '%Y-%m-%d')
ed = datetime.strptime(end_date_str, '%Y-%m-%d')
start_date_dt = BISHKEK_TZ.localize(sd.replace(hour=0, minute=0, second=0, microsecond=0))
end_date_dt = BISHKEK_TZ.localize(ed.replace(hour=23, minute=59, second=59, microsecond=999999))
elif filter_type == 'day':
day_to_use_str = date_str if date_str else now.strftime('%Y-%m-%d')
d = datetime.strptime(day_to_use_str, '%Y-%m-%d')
start_date_dt = BISHKEK_TZ.localize(d.replace(hour=0, minute=0, second=0, microsecond=0))
end_date_dt = start_date_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
elif filter_type == 'week':
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_date_dt = today_start - timedelta(days=today_start.weekday())
end_date_dt = start_date_dt + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999)
elif filter_type == 'year':
year_to_use_str = year_str if year_str else str(now.year)
year_int = int(year_to_use_str)
start_date_dt = BISHKEK_TZ.localize(datetime(year_int, 1, 1, 0, 0, 0))
end_date_dt = BISHKEK_TZ.localize(datetime(year_int, 12, 31, 23, 59, 59, 999999))
else: # По умолчанию 'month'
month_to_use_str = month_str if month_str else now.strftime('%Y-%m')
year, month = map(int, month_to_use_str.split('-'))
start_date_dt = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0))
next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1)
end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
if not start_date_dt or not end_date_dt or start_date_dt > end_date_dt:
raise ValueError("Некорректный временной диапазон.")
except (ValueError, TypeError) as e:
flash(f"Ошибка в задании периода: {e}. Отображен отчет за текущий месяц.", "warning")
filter_type = 'month'
start_date_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1)
end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
# --- Фильтрация данных по дате ---
filtered_packed_items = []
all_packed_items_raw = data.get('qc_packing_items', [])
for item_raw in all_packed_items_raw:
if isinstance(item_raw, dict) and 'id' in item_raw:
item_data = find_item_by_id(item_raw['id'], 'qc_packing_items')
if not item_data: continue
packed_time = parse_iso_datetime(item_data.get('timestamp_packed'))
if packed_time and start_date_dt <= packed_time <= end_date_dt:
shipment_time = None
shipment_details = item_data.get('shipment_details')
if shipment_details and shipment_details.get('timestamp'):
shipment_time = parse_iso_datetime(shipment_details.get('timestamp'))
item_data['shipment_time_dt'] = shipment_time
filtered_packed_items.append(item_data)
all_defect_log_raw = data.get('defect_log', [])
filtered_defects = []
for defect_raw in all_defect_log_raw:
if isinstance(defect_raw, dict) and 'log_id' in defect_raw:
defect_data = find_item_by_id(defect_raw['log_id'], 'defect_log')
if not defect_data: continue
defect_time = parse_iso_datetime(defect_data.get('timestamp'))
if defect_time and start_date_dt <= defect_time <= end_date_dt:
filtered_defects.append(defect_data)
all_expenses_raw = data.get('expenses', [])
filtered_expenses = []
for expense_raw in all_expenses_raw:
if isinstance(expense_raw, dict) and 'id' in expense_raw:
expense_data = find_item_by_id(expense_raw['id'], 'expenses')
if not expense_data: continue
expense_time = parse_iso_datetime(expense_data.get('timestamp'))
if expense_time and start_date_dt <= expense_time <= end_date_dt:
filtered_expenses.append(expense_data)
# --- Расчет итоговых показателей за период ---
total_packed_quantity = sum(item.get('quantity', 0) for item in filtered_packed_items)
total_revenue = sum(item.get('packed_final_price', Decimal('0')) for item in filtered_packed_items)
total_material_cost_packed = sum(item.get('packed_material_cost', Decimal('0')) for item in filtered_packed_items)
# Общая ЗП из упакованных товаров (уже включает все 3 этапа)
total_salary_cost_packed = sum(item.get('packed_salary_cost', Decimal('0')) for item in filtered_packed_items)
total_cost_packed = total_material_cost_packed + total_salary_cost_packed
total_defect_cost = sum(defect.get('cost_dec', Decimal('0')) for defect in filtered_defects)
total_expenses_cost = sum(expense.get('amount', Decimal('0')) for expense in filtered_expenses)
total_overall_cost = total_cost_packed + total_defect_cost + total_expenses_cost
total_profit = total_revenue - total_overall_cost
# --- Расчет детализации ЗП по этапам за период ---
total_cutter_salary = Decimal('0')
total_sewer_salary = Decimal('0')
total_packer_salary = Decimal('0')
cutter_rate = to_decimal(config.get('salary_cutter_per_unit', '0'))
sewer_rate = to_decimal(config.get('salary_sewer_per_unit', '0'))
packer_rate = to_decimal(config.get('salary_packer_per_unit', '0'))
for item in filtered_packed_items:
qty = item.get('quantity', 0)
if qty > 0:
# Расчет ЗП основан на количестве упакованных в этот период
# Получаем связанные задачи, чтобы понять, сколько было раскроено/сшито ИЗНАЧАЛЬНО
# Это не совсем точно, если задача шилась/кроилась в другом периоде,
# но для простоты считаем ЗП по упакованным в этот период по текущим ставкам.
total_cutter_salary += Decimal(qty) * cutter_rate
total_sewer_salary += Decimal(qty) * sewer_rate
total_packer_salary += Decimal(qty) * packer_rate
calculated_total_salary = total_cutter_salary + total_sewer_salary + total_packer_salary
if total_packed_quantity > 0 and abs(calculated_total_salary - total_salary_cost_packed) > Decimal('0.01') * total_packed_quantity :
logging.warning(f"Расчетная детализация ЗП ({calculated_total_salary}) не совпадает с общей ЗП из упаковок ({total_salary_cost_packed}). Возможны расхождения в логике или округлении.")
# --- Сводка по продуктам ---
production_summary = {}
for item in filtered_packed_items:
product_name = item.get('product_name', 'Неизвестный продукт')
quantity = item.get('quantity', 0)
revenue = item.get('packed_final_price', Decimal('0'))
cost = item.get('packed_total_cost', Decimal('0')) # Используем общую себестоимость партии
profit = revenue - cost
if product_name not in production_summary:
production_summary[product_name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')}
production_summary[product_name]['quantity'] += quantity
production_summary[product_name]['revenue'] += revenue
production_summary[product_name]['cost'] += cost
production_summary[product_name]['profit'] += profit
# --- Подготовка данных для шаблона ---
report_data = {
'total_packed_qty': total_packed_quantity,
'total_revenue': total_revenue,
'total_material_cost': total_material_cost_packed,
'total_salary_cost': total_salary_cost_packed, # Общая ЗП
'total_cost_packed': total_cost_packed,
'total_defect_cost': total_defect_cost,
'total_expenses': total_expenses_cost,
'total_overall_cost': total_overall_cost,
'total_profit': total_profit,
# Добавлена детализация ЗП
'total_cutter_salary': total_cutter_salary,
'total_sewer_salary': total_sewer_salary,
'total_packer_salary': total_packer_salary,
# ---
'production_summary': production_summary,
'filtered_packed_items': filtered_packed_items,
'filtered_defects': filtered_defects,
'filtered_expenses': filtered_expenses,
'start_date': start_date_dt.strftime('%Y-%m-%d'),
'end_date': end_date_dt.strftime('%Y-%m-%d'),
'filter_type': filter_type,
'current_day': now.strftime('%Y-%m-%d'),
'current_month': now.strftime('%Y-%m'),
'current_year': now.year,
'filter_values': request.args
}
html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS)
return render_template_string(html, report=report_data) # Передаем весь словарь report_data
# 9. Маршрут "Облако"
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/cloud', methods=['GET', 'POST'])
def cloud_storage():
data = load_data()
cloud_files = data.get('cloud_files', [])
if request.method == 'POST':
description = request.form.get('description', '').strip()
if 'file' not in request.files:
flash('Файл не был выбран.', 'warning')
return redirect(url_for('cloud_storage'))
file = request.files['file']
if file.filename == '':
flash('Файл не был выбран.', 'warning')
return redirect(url_for('cloud_storage'))
if file and allowed_file(file.filename):
original_filename = secure_filename(file.filename)
# Генерируем уникальное имя файла для хранения
file_ext = original_filename.rsplit('.', 1)[1].lower()
stored_filename = f"{uuid.uuid4().hex}.{file_ext}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename)
thumbnail_filename = None
try:
file.save(file_path)
logging.info(f"Файл '{original_filename}' сохранен как '{stored_filename}'")
# Создаем миниатюру, если это изображение
if file_ext in {'png', 'jpg', 'jpeg', 'gif'}:
thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], stored_filename)
thumbnail_filename = create_thumbnail(file_path, thumb_path) # Вернет имя файла или None
# Сохраняем метаданные
file_meta = {
'file_id': uuid.uuid4().hex, # ID для управления записью
'original_filename': original_filename,
'stored_filename': stored_filename,
'thumbnail_filename': thumbnail_filename, # Имя файла миниатюры или None
'description': description,
'timestamp': get_current_time().isoformat(),
'size': os.path.getsize(file_path) # Размер файла в байтах
}
cloud_files.append(file_meta)
data['cloud_files'] = cloud_files
save_data(data)
flash(f"Файл '{original_filename}' успешно загружен.", 'success')
upload_db_to_hf(DATA_FILE) # Бэкап
except Exception as e:
logging.error(f"Ошибка при сохранении файла или создании миниатюры: {e}", exc_info=True)
flash(f"Ошибка при загрузке файла: {e}", 'danger')
# Удаляем частично загруженный файл, если он есть
if os.path.exists(file_path):
try: os.remove(file_path)
except OSError: pass
return redirect(url_for('cloud_storage'))
else:
flash('Недопустимый тип файла.', 'danger')
return redirect(url_for('cloud_storage'))
# GET запрос
search_query = request.args.get('search', '').lower()
if search_query:
filtered_files = [
f for f in cloud_files
if search_query in f.get('description', '').lower() or \
search_query in f.get('original_filename', '').lower()
]
else:
filtered_files = cloud_files
# Сортируем по дате добавления (сначала новые)
filtered_files.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
# ИСПРАВЛЕНО: Используем CLOUD_CONTENT
html = BASE_TEMPLATE.replace('__TITLE__', "Облачное хранилище").replace('__CONTENT__', CLOUD_CONTENT).replace('__SCRIPTS__', CLOUD_SCRIPTS)
return render_template_string(html, files=filtered_files, search_query=search_query)
# 10. Маршрут для скачивания файла из облака
@app.route('/download_file/<filename>')
def download_file(filename):
# Важно: НЕ используйте secure_filename здесь, т.к. имя файла уже уникально
# и может не совпадать с оригинальным!
try:
# Используем send_from_directory для безопасной отправки файла
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
except FileNotFoundError:
flash("Файл не найден.", "danger")
return redirect(url_for('cloud_storage'))
except Exception as e:
logging.error(f"Ошибка при скачивании файла {filename}: {e}", exc_info=True)
flash(f"Ошибка при скачивании файла: {e}", "danger")
return redirect(url_for('cloud_storage'))
# 11. Маршрут для отображения миниатюр
@app.route('/thumbnail/<filename>')
def get_thumbnail(filename):
try:
return send_from_directory(app.config['THUMBNAIL_FOLDER'], filename)
except FileNotFoundError:
# Можно вернуть заглушку или 404
# Убедитесь, что у вас есть файл 'static/placeholder.png' или измените путь
# Для простоты вернем 404, если заглушки нет
return "Thumbnail not found", 404
# try:
# return send_from_directory('static', 'placeholder.png')
# except: # Если и заглушка не найдена
# return "Placeholder not found", 404
except Exception as e:
logging.error(f"Ошибка при отдаче миниатюры {filename}: {e}")
return "Error getting thumbnail", 500
# try:
# return send_from_directory('static', 'placeholder.png')
# except:
# return "Placeholder not found", 404
# 12. Маршрут для удаления файла из облака
@app.route('/cloud/delete/<file_id>', methods=['POST'])
def delete_cloud_file(file_id):
data = load_data()
cloud_files = data.get('cloud_files', [])
file_to_delete = None
file_index = -1
for i, f in enumerate(cloud_files):
if isinstance(f, dict) and f.get('file_id') == file_id: # Добавлена проверка на dict
file_to_delete = f
file_index = i
break
if file_to_delete:
stored_filename = file_to_delete.get('stored_filename')
thumb_filename = file_to_delete.get('thumbnail_filename')
file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) if stored_filename else None
thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_filename) if thumb_filename else None
try:
# Удаляем запись из данных
del cloud_files[file_index]
data['cloud_files'] = cloud_files
save_data(data) # Сохраняем изменения в JSON
# Удаляем физический файл
if file_path and os.path.exists(file_path):
os.remove(file_path)
logging.info(f"Удален файл: {file_path}")
# Удаляем миниатюру
if thumb_path and os.path.exists(thumb_path):
os.remove(thumb_path)
logging.info(f"Удалена миниатюра: {thumb_path}")
flash(f"Файл '{file_to_delete.get('original_filename', 'N/A')}' успешно удален.", 'success')
upload_db_to_hf(DATA_FILE) # Бэкап
except Exception as e:
logging.error(f"Ошибка при удалении файла {file_id}: {e}", exc_info=True)
flash(f"Ошибка при удалении файла: {e}", 'danger')
# Важно: Не откатываем удаление записи из JSON, если файл не удалился,
# чтобы избежать рассинхронизации. Лучше иметь запись без файла, чем файл без записи.
else:
flash('Файл для удаления не найден.', 'warning')
return redirect(url_for('cloud_storage'))
# --- HTML Шаблоны ---
# Контент заказов
ORDERS_CONTENT = """
<div class="card">
<div class="card-header"><i class="fas fa-clipboard-list"></i>Создание заказа</div>
<div class="card-body">
<form method="POST" id="order-form">
<div class="row mb-3">
<div class="col-md-6">
<label for="client_id" class="form-label">Клиент:</label>
<select id="client_id" name="client_id" class="form-select" required>
<option value="">-- Выберите клиента --</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }} ({{ client.phone }})</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="model_name" class="form-label">Модель (артикул/название):</label>
<input type="text" id="model_name" name="model_name" class="form-control" required>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="fabric_name" class="form-label">Название ткани:</label>
<input type="text" id="fabric_name" name="fabric_name" class="form-control" required>
</div>
<div class="col-md-4">
<label for="fabric_quantity" class="form-label">Количество ткани:</label>
<input type="text" id="fabric_quantity" name="fabric_quantity" class="form-control" required inputmode="decimal">
</div>
<div class="col-md-4">
<label for="items_quantity" class="form-label">Количество изделий:</label>
<input type="number" id="items_quantity" name="items_quantity" class="form-control" required min="1" step="1">
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<label for="size_range" class="form-label">Размерный ряд:</label>
<input type="text" id="size_range" name="size_range" class="form-control">
</div>
</div>
<div id="fittings-container">
<h6>Фурнитура:</h6>
<div id="fittings-rows">
<div class="row mb-2 fitting-row">
<div class="col-md-6">
<input type="text" name="fitting_names[]" class="form-control form-control-sm" placeholder="Название фурнитуры">
</div>
<div class="col-md-4">
<input type="number" name="fitting_quantities[]" class="form-control form-control-sm" placeholder="Количество" min="1" step="1">
</div>
<div class="col-md-2">
<button type="button" class="btn btn-sm btn-danger remove-fitting" onclick="removeFitting(this)"><i class="fas fa-times"></i></button>
</div>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addFitting()">
<i class="fas fa-plus"></i> Добавить фурнитуру
</button>
</div>
<hr>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Создать заказ
</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><i class="fas fa-list"></i>Список заказов</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Клиент</th>
<th>Модель</th>
<th>Ткань</th>
<th>Кол-во ткани</th>
<th>Фурнитура</th>
<th>Кол-во изделий</th>
<th>Размерный ряд</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td title="{{ order.id }}"><small>{{ order.id[:8] }}...</small></td>
<td>{{ order.client_name }}</td>
<td>{{ order.model_name }}</td>
<td>{{ order.fabric_name }}</td>
<td>{{ order.fabric_quantity }} {{ order.fabric_unit }}</td>
<td>
<ul class="list-unstyled mb-0 small">
{% for f in order.fittings %}
<li>{{ f.fitting_name }}: {{ f.quantity }} шт.</li>
{% endfor %}
</ul>
</td>
<td>{{ order.items_quantity }}</td>
<td>{{ order.size_range }}</td>
<td><span class="{{ getStatusClass(order.status) }}">{{ getStatusText(order.status) }}</span></td>
<td>{{ order.timestamp_created[:16]|replace('T', ' ') }}</td>
<td>
{% if order.status != 'completed' %}
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-info" onclick="editOrder('{{ order.id }}')">
<i class="fas fa-edit"></i>
</button>
<form method="POST" action="{{ url_for('delete_order', order_id=order.id) }}"
class="d-inline" onsubmit="return confirm('Удалить заказ?');">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="11" class="text-center text-muted">Заказы отсутствуют.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
# Скрипты заказов
ORDERS_SCRIPTS = """
<script>
function addFitting() {
const container = document.getElementById('fittings-rows');
const firstRow = container.querySelector('.fitting-row');
const newRow = firstRow.cloneNode(true);
newRow.querySelectorAll('input').forEach(input => input.value = '');
container.appendChild(newRow);
}
function removeFitting(button) {
const row = button.closest('.fitting-row');
if (document.querySelectorAll('.fitting-row').length > 1) {
row.remove();
}
}
function editOrder(orderId) {
// Здесь будет логика редактирования
alert('Редактирование заказа ' + orderId);
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('order-form');
if (form) {
form.addEventListener('submit', function(e) {
// Добавить валидацию формы при необходимости
});
}
});
</script>
"""
# ОБЫЧНЫЙ Базовый шаблон (с навигацией) - ИЗМЕНЕНЫ СТИЛИ ВКЛАДОК
BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__ - КШП</title> {# Title Changed #}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<style>
body { background-color: #f8f9fa; font-family: 'Segoe UI', sans-serif; padding-top: 70px; padding-bottom: 60px; position: relative; min-height: 100vh; }
.navbar { background-color: #343a40; box-shadow: 0 2px 4px rgba(0,0,0,.1); }
.navbar-brand, .nav-link { color: #f8f9fa !important; }
.navbar-brand:hover, .nav-link:hover { color: #adb5bd !important; }
.nav-link.active { font-weight: bold; color: #ffffff !important; border-bottom: 2px solid #0d6efd; }
.card { margin-bottom: 1.5rem; border: none; border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); }
.card-header { background-color: #6c757d; color: white; font-weight: 600; border-bottom: none; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; display: flex; align-items: center; }
.card-header i { margin-right: 0.5rem; }
.btn-primary { background-color: #0d6efd; border-color: #0d6efd; }
.btn-primary:hover { background-color: #0b5ed7; border-color: #0a58ca; }
.btn-success { background-color: #198754; border-color: #198754; }
.btn-danger { background-color: #dc3545; border-color: #dc3545; }
.btn-warning { background-color: #ffc107; border-color: #ffc107; color: #212529; }
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #000; }
.table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.05); }
.container-main { max-width: 1600px; margin: 0 auto; padding-left: 15px; padding-right: 15px; }
.status-pending { color: #0dcaf0; font-weight: bold; }
.status-completed { color: #198754; font-weight: bold; }
.status-pending_qc { color: #ffc107; font-weight: bold; }
.status-packed_ready_to_ship { color: #6f42c1; font-weight: bold; }
.status-shipped_client { color: #0d6efd; font-weight: bold; }
.status-shipped_dor_doi { color: #20c997; font-weight: bold; }
.text-ready { color: #6f42c1 !important; }
.text-shipped-client { color: #0d6efd !important; }
.text-shipped-dordoi { color: #20c997 !important; }
.flash-messages .alert { margin-bottom: 1rem; border-radius: 0.5rem; }
.dynamic-row, .dynamic-fitting-row, .dynamic-defect-row { border: 1px solid #eee; padding: 15px; margin-bottom: 15px; border-radius: 5px; background-color: #fdfdfd; position: relative; }
.remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn { position: absolute; top: 10px; right: 10px; z-index: 10; }
.table-responsive { margin-bottom: 1rem; }
.table th, .table td { vertical-align: middle; font-size: 0.88rem; }
/* Добавляем рамки к ячейкам таблицы */
.table-bordered th, .table-bordered td { border: 1px solid #dee2e6; }
.table td small { color: #6c757d; display: block; font-size: 0.75rem; }
.table .badge { font-size: 0.8em; }
.table th i.fa-sort, .table th i.fa-sort-up, .table th i.fa-sort-down { margin-left: 5px; color: #adb5bd; cursor: pointer; }
.table th:hover i { color: #343a40; }
.footer { position: relative; margin-top: 3rem; background-color: #343a40; color: #f8f9fa; text-align: center; padding: 10px 0; font-size: 0.9em; }
input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number] { -moz-appearance: textfield; }
.form-text { font-size: 0.8rem; }
.text-success { color: #198754 !important; }
.text-danger { color: #dc3545 !important; }
.text-warning { color: #ffc107 !important; }
.text-info { color: #0dcaf0 !important; }
.fw-bold { font-weight: 600 !important; }
.card-body .table { margin-bottom: 0; }
.modal-body .list-group-item { font-size: 0.9rem; }
.form-select-sm { padding-top: 0.25rem; padding-bottom: 0.25rem; font-size: .875rem; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: .875rem; }
.clickable-card { cursor: pointer; transition: all 0.2s ease-in-out; } /* Для кликабельных карточек */
.clickable-card:hover { box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15); transform: translateY(-2px); } /* Эффект при наведении */
.cloud-thumbnail { max-width: 80px; max-height: 80px; object-fit: cover; border-radius: 0.25rem; border: 1px solid #dee2e6;}
/* --- НАЧАЛО ИСПРАВЛЕНИЯ: Улучшенная видимость вкладок Bootstrap --- */
.nav-tabs .nav-link {
border: 1px solid #dee2e6;
border-bottom-color: transparent;
color: #212529; /* ИЗМЕНЕНО: Стандартный темный цвет текста Bootstrap */
background-color: #e9ecef; /* Светло-серый фон для неактивных */
margin-right: 2px;
margin-bottom: -1px;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; /* Плавный переход */
}
.nav-tabs .nav-link:hover,
.nav-tabs .nav-link:focus {
border-color: #e9ecef #e9ecef #dee2e6;
background-color: #f8f9fa; /* Фон при наведении */
color: #0d6efd; /* Цвет текста при наведении */
isolation: isolate;
}
.nav-tabs .nav-link.active {
color: #0d6efd; /* Основной цвет для текста активной вкладки */
background-color: #fff; /* Белый фон для активной вкладки (как у .card-body) */
border-color: #dee2e6 #dee2e6 #fff; /* Рамки для соединения с контентом */
font-weight: bold; /* Жирный шрифт для активной */
}
/* --- КОНЕЦ ИСПРАВЛЕНИЯ --- */
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark fixed-top">
<div class="container-fluid container-main">
<a class="navbar-brand" href="{{ url_for('admin_panel') }}"><i class="fas fa-industry me-2"></i>КШП</a> {# Title Changed #}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'orders' %}active{% endif %}" href="{{ url_for('orders') }}"><i class="fas fa-clipboard-list me-1"></i>Заказы</a>
</li>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'procurement' %}active{% endif %}" href="{{ url_for('procurement') }}"><i class="fas fa-shopping-cart me-1"></i>Закуп</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'cutting' %}active{% endif %}" href="{{ url_for('cutting') }}"><i class="fas fa-cut me-1"></i>Раскрой</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'sewing' %}active{% endif %}" href="{{ url_for('sewing') }}"><i class="fas fa-tshirt me-1"></i>Пошив</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'qc_packing' %}active{% endif %}" href="{{ url_for('qc_packing') }}"><i class="fas fa-box-open me-1"></i>ОТК и Упаковка</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'clients_panel' %}active{% endif %}" href="{{ url_for('clients_panel') }}"><i class="fas fa-users me-1"></i>Клиенты</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'reports' %}active{% endif %}" href="{{ url_for('reports') }}"><i class="fas fa-chart-line me-1"></i>Отчеты</a>
</li>
{# --- ДОБАВЛЕНО: Ссылка на Облако --- #}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'cloud_storage' %}active{% endif %}" href="{{ url_for('cloud_storage') }}"><i class="fas fa-cloud me-1"></i>Облако</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'advances' %}active{% endif %}" href="{{ url_for('advances') }}"><i class="fas fa-hand-holding-usd me-1"></i>Авансы</a>
</li>
{# --- КОНЕЦ ДОБАВЛЕНИЯ --- #}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin_panel' %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-tachometer-alt me-1"></i>Админ-панель</a>
</li>
</ul>
<div class="d-flex align-items-center">
<form method="POST" action="{{ url_for('backup_hf') }}" class="me-2 mb-0">
<button type="submit" class="btn btn-sm btn-outline-light" title="Backup to Hugging Face"><i class="fas fa-cloud-upload-alt"></i> Backup DBs</button>
</form>
<form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Перезапишет локальные данные (основные и клиентов) с Hugging Face. Уверены?');" class="mb-0">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Download DBs from Hugging Face"><i class="fas fa-cloud-download-alt"></i> Download DBs</button>
</form>
</div>
</div>
</div>
</nav>
<div class="container-main mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
{% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %}
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
__CONTENT__
</div>
<footer class="footer">
<div class="container-main">
<p class="mb-0">&copy; {{ get_current_time().year }} КШП. Все права защищены. ({{ get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') }})</p> {# Title Changed #}
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://npmcdn.com/flatpickr/dist/l10n/ru.js"></script>
<script>
// Глобальная функция для форматирования чисел как валюты (JS)
function formatCurrencyJS(value) { try { const number = parseFloat(String(value).replace(/\s/g, '').replace(',', '.')); if (isNaN(number)) { return '0,00'; } return number.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } catch (e) { return '0,00'; } }
// Глобальная функция для форматирования целых чисел (JS)
function formatIntegerJS(value) { try { const number = parseInt(String(value).replace(/\s/g, '').replace(',', '.').split('.')[0]); if (isNaN(number)) { return '0'; } return number.toLocaleString('ru-RU'); } catch (e) { return '0'; } }
// Глобальная функция для получения текущего времени из футера
function getCurrentTimeFromFooter() { const footer = document.querySelector('.footer p'); const timeMatch = footer ? footer.textContent.match(/\((.*?)\)/) : null; return timeMatch ? timeMatch[1] : new Date().toLocaleString(); }
// Вспомогательные JS функции для статусов (могут быть полезны для динамики)
function getStatusTextJS(statusKey) { const map = {'pending': 'Ожидает пошива','completed': 'Завершено','pending_qc': 'Ожидает ОТК','packed_ready_to_ship': 'Готово к отправке','shipped_client': 'Отправлено клиенту','shipped_dor_doi': 'Отправлено на Дордой'}; return map[statusKey] || statusKey; }
function getStatusClassJS(statusKey) { const map = {'pending': 'status-pending text-info','completed': 'status-completed text-success','pending_qc': 'status-pending_qc text-warning','packed_ready_to_ship': 'status-packed_ready_to_ship text-ready','shipped_client': 'status-shipped_client text-shipped-client','shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi'}; return map[statusKey] || ''; }
// Глобальная функция сортировки таблиц (вызывается из th onclick)
function sortTable(columnIndex, tableId, isNumeric = false) {
const table = document.getElementById(tableId); if (!table) return;
const tbody = table.querySelector('tbody'); const headerRow = table.querySelector('thead tr'); if (!tbody || !headerRow) return;
const rows = Array.from(tbody.querySelectorAll('tr:not(.no-result-row)')); if (rows.length < 2) return;
const headerCell = headerRow.querySelector(`th:nth-child(${columnIndex + 1})`); if (!headerCell) return;
let currentDir = headerCell.dataset.sortDir || 'asc'; let newDir = currentDir === 'asc' ? 'desc' : 'asc';
headerRow.querySelectorAll('th').forEach((th, index) => {
const icon = th.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down');
if (icon) { icon.className = (index === columnIndex) ? `fas fa-sort-${newDir === 'asc' ? 'up' : 'down'}` : 'fas fa-sort'; }
th.dataset.sortDir = (index === columnIndex) ? newDir : '';
});
rows.sort((a, b) => {
let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`); let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`);
let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : ''; let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : '';
let comparison = 0;
if (isNumeric) {
valA = parseFloat(String(valA).replace(/\s/g, '').replace(',', '.')) || 0; valB = parseFloat(String(valB).replace(/\s/g, '').replace(',', '.')) || 0; comparison = valA - valB;
} else { valA = valA.toLowerCase(); valB = valB.toLowerCase(); comparison = valA.localeCompare(valB, 'ru'); }
return newDir === 'asc' ? comparison : -comparison;
});
rows.forEach(row => tbody.appendChild(row));
}
</script>
__SCRIPTS__
</body>
</html>
"""
# ОПЕРАЦИОННЫЙ Базовый шаблон (БЕЗ навигации и БЕЗ кнопки "Назад")
BASE_TEMPLATE_OPERATIONAL = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__ - КШП</title> {# Title Changed #}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<style>
body { background-color: #f8f9fa; font-family: 'Segoe UI', sans-serif; padding-top: 20px; padding-bottom: 60px; position: relative; min-height: 100vh; }
.card { margin-bottom: 1.5rem; border: none; border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); }
.card-header { background-color: #6c757d; color: white; font-weight: 600; border-bottom: none; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; display: flex; align-items: center; }
.card-header i { margin-right: 0.5rem; }
.btn-primary { background-color: #0d6efd; border-color: #0d6efd; }
.btn-primary:hover { background-color: #0b5ed7; border-color: #0a58ca; }
.btn-success { background-color: #198754; border-color: #198754; }
.btn-danger { background-color: #dc3545; border-color: #dc3545; }
.btn-warning { background-color: #ffc107; border-color: #ffc107; color: #212529; }
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #000; }
.table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.05); }
.container-main { max-width: 1600px; margin: 0 auto; padding-left: 15px; padding-right: 15px; }
.status-pending { color: #0dcaf0; font-weight: bold; }
.status-completed { color: #198754; font-weight: bold; }
.status-pending_qc { color: #ffc107; font-weight: bold; }
.status-packed_ready_to_ship { color: #6f42c1; font-weight: bold; }
.status-shipped_client { color: #0d6efd; font-weight: bold; }
.status-shipped_dor_doi { color: #20c997; font-weight: bold; }
.text-ready { color: #6f42c1 !important; }
.text-shipped-client { color: #0d6efd !important; }
.text-shipped-dordoi { color: #20c997 !important; }
.flash-messages .alert { margin-bottom: 1rem; border-radius: 0.5rem; }
.dynamic-row, .dynamic-fitting-row, .dynamic-defect-row { border: 1px solid #eee; padding: 15px; margin-bottom: 15px; border-radius: 5px; background-color: #fdfdfd; position: relative; }
.remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn { position: absolute; top: 10px; right: 10px; z-index: 10; }
.table-responsive { margin-bottom: 1rem; }
.table th, .table td { vertical-align: middle; font-size: 0.88rem; }
/* Добавляем рамки к ячейкам таблицы */
.table-bordered th, .table-bordered td { border: 1px solid #dee2e6; }
.table td small { color: #6c757d; display: block; font-size: 0.75rem; }
.table .badge { font-size: 0.8em; }
.table th i.fa-sort, .table th i.fa-sort-up, .table th i.fa-sort-down { margin-left: 5px; color: #adb5bd; cursor: pointer; }
.table th:hover i { color: #343a40; }
.footer { position: relative; margin-top: 3rem; background-color: #343a40; color: #f8f9fa; text-align: center; padding: 10px 0; font-size: 0.9em; }
input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number] { -moz-appearance: textfield; }
.form-text { font-size: 0.8rem; }
.text-success { color: #198754 !important; }
.text-danger { color: #dc3545 !important; }
.text-warning { color: #ffc107 !important; }
.text-info { color: #0dcaf0 !important; }
.fw-bold { font-weight: 600 !important; }
.card-body .table { margin-bottom: 0; }
.modal-body .list-group-item { font-size: 0.9rem; }
.form-select-sm { padding-top: 0.25rem; padding-bottom: 0.25rem; font-size: .875rem; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: .875rem; }
</style>
</head>
<body>
{# --- НАВИГАЦИЯ УБРАНА --- #}
<div class="container-main mt-4">
{# --- КНОПКА НАЗАД УБРАНА --- #}
<h2 class="mb-4">__TITLE__</h2> {# Добавлен заголовок страницы #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
{% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %}
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
__CONTENT__
</div>
<footer class="footer">
<div class="container-main">
<p class="mb-0">&copy; {{ get_current_time().year }} КШП. Все права защищены. ({{ get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') }})</p> {# Title Changed #}
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://npmcdn.com/flatpickr/dist/l10n/ru.js"></script>
<script>
// JS Форматирование и хелперы статусов (остаются здесь)
function formatCurrencyJS(value) { try { const number = parseFloat(String(value).replace(/\s/g, '').replace(',', '.')); if (isNaN(number)) { return '0,00'; } return number.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } catch (e) { return '0,00'; } }
function formatIntegerJS(value) { try { const number = parseInt(String(value).replace(/\s/g, '').replace(',', '.').split('.')[0]); if (isNaN(number)) { return '0'; } return number.toLocaleString('ru-RU'); } catch (e) { return '0'; } }
function getCurrentTimeFromFooter() { const footer = document.querySelector('.footer p'); const timeMatch = footer ? footer.textContent.match(/\((.*?)\)/) : null; return timeMatch ? timeMatch[1] : new Date().toLocaleString(); }
function getStatusTextJS(statusKey) { const map = {'pending': 'Ожидает пошива','completed': 'Завершено','pending_qc': 'Ожидает ОТК','packed_ready_to_ship': 'Готово к отправке','shipped_client': 'Отправлено клиенту','shipped_dor_doi': 'Отправлено на Дордой'}; return map[statusKey] || statusKey; }
function getStatusClassJS(statusKey) { const map = {'pending': 'status-pending text-info','completed': 'status-completed text-success','pending_qc': 'status-pending_qc text-warning','packed_ready_to_ship': 'status-packed_ready_to_ship text-ready','shipped_client': 'status-shipped_client text-shipped-client','shipped_dor_doi': 'status-shipped_dor_doi text-shipped-dordoi'}; return map[statusKey] || ''; }
</script>
__SCRIPTS__
</body>
</html>
"""
# Контент закупки
PROCUREMENT_CONTENT = """
<!-- Orders List -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-clipboard-list"></i> Активные заказы</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Клиент</th>
<th>Изделие</th>
<th>Ткань</th>
<th>Кол-во ткани</th>
<th>Кол-во изделий</th>
<th>Размерный ряд</th>
<th>Фурнитура</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
{% if order.status == 'pending_procurement' and not order.is_procured %}
<tr>
<td><small>{{ order.id[:8] }}...</small></td>
<td>{{ order.client_name }}</td>
<td>{{ order.model_name }}</td>
<td>{{ order.fabric_name }}</td>
<td>{{ order.fabric_quantity }}</td>
<td>{{ order.items_quantity }}</td>
<td>{{ order.size_range }}</td>
<td>
{% if order.fittings %}
<ul class="list-unstyled mb-0 small">
{% for f in order.fittings %}
<li>{{ f.fitting_name }}: {{ f.quantity }} шт.</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">Нет фурнитуры</span>
{% endif %}
</td>
<td>{{ order.timestamp_created[:16]|replace('T', ' ') }}</td>
<td>
<form method="POST" action="{{ url_for('procurement') }}" style="display: inline;">
<input type="hidden" name="order_id" value="{{ order.id }}">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Отметить заказ как закупленный?')">
<i class="fas fa-check"></i> Закуплено
</button>
</form>
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="10" class="text-center text-muted">Нет активных заказов.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><i class="fas fa-shopping-cart"></i> Добавить закупленные материалы</div>
<div class="card-body">
<form method="POST" id="procurement-form">
<div id="material-rows">
<!-- Шаблон строки -->
<div class="row dynamic-row mb-3 align-items-end">
<div class="col-md-3 mb-2">
<label class="form-label">Название:</label>
<input type="text" name="item_name[]" class="form-control form-control-sm" required>
</div>
<div class="col-md-1 mb-2">
<label class="form-label">Кол-во:</label>
<input type="text" name="item_quantity[]" class="form-control form-control-sm quantity-input" placeholder="10,5" required inputmode="decimal">
</div>
<div class="col-md-1 mb-2">
<label class="form-label">Ед.:</label>
<select name="item_unit[]" class="form-select form-select-sm" required>
<option value="м">м</option> <option value="кг">кг</option> <option value="шт">шт</option> <option value="пач">пач</option> <option value="рул">рул</option> <option value="упак">упак</option>
</select>
</div>
<div class="col-md-2 mb-2">
<label class="form-label">Цена за ед.:</label>
<input type="text" name="item_price_per_unit[]" class="form-control form-control-sm" placeholder="150,99" required inputmode="decimal">
</div>
<div class="col-md-2 mb-2">
<label class="form-label">Расход на одно изделие:</label>
<input type="text" name="item_per_unit[]" class="form-control form-control-sm per-unit-input" placeholder="0,5" inputmode="decimal">
<small class="text-muted calculation-result" style="font-size: 0.7rem;"></small>
</div>
<div class="col-md-1 mb-2">
<label class="form-label">Тип:</label>
<select name="item_type[]" class="form-select form-select-sm" required>
<option value="fabric">Ткань</option> <option value="fittings">Фурн-ра</option>
</select>
</div>
<div class="col-md-2 mb-2"> {# Уменьшил ширину категории #}
<label class="form-label">Категория:</label>
<select name="item_category[]" class="form-select form-select-sm category-select">
<option value="Без категории">Без категории</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
<option value="__new__">-- Добавить новую --</option>
</select>
<input type="text" name="item_new_category[]" class="form-control form-control-sm mt-1 new-category-input" style="display: none;" placeholder="Новая категория">
</div>
<button type="button" class="btn btn-sm btn-danger remove-row-btn" onclick="removeRow(this)" title="Удалить строку"><i class="fas fa-trash"></i></button>
</div>
<!-- Конец шаблона строки -->
</div>
<button type="button" class="btn btn-sm btn-outline-success me-2" onclick="addRow()"><i class="fas fa-plus"></i> Добавить строку</button>
<button type="submit" class="btn btn-primary"><i class="fas fa-check me-1"></i>Засчитать закуп</button>
</form>
</div>
</div>
"""
# Скрипты закупки
PROCUREMENT_SCRIPTS = """
<script>
function calculateItemsFromFabric(row) {
const quantityInput = row.querySelector('.quantity-input');
const perUnitInput = row.querySelector('.per-unit-input');
const calculationResult = row.querySelector('.calculation-result');
if (quantityInput && perUnitInput && calculationResult) {
const quantity = parseFloat(quantityInput.value.replace(',', '.')) || 0;
const materialPerUnit = parseFloat(perUnitInput.value.replace(',', '.')) || 0;
if (quantity > 0 && materialPerUnit > 0) {
const totalItems = Math.floor(quantity / materialPerUnit);
calculationResult.textContent = `При расходе ${materialPerUnit} на изделие, хватит на ${totalItems} шт.`;
calculationResult.style.color = '#198754'; // Bootstrap success color
} else {
calculationResult.textContent = 'Введите оба значения';
calculationResult.style.color = '#6c757d'; // Bootstrap muted color
}
}
}
function addRow() {
const container = document.getElementById('material-rows');
const firstRow = container.querySelector('.dynamic-row');
if (!firstRow) return;
const newRow = firstRow.cloneNode(true);
newRow.querySelectorAll('input[type="text"], input[type="number"]').forEach(i => i.value = '');
newRow.querySelectorAll('select').forEach(s => {
if (s.name === 'item_unit[]') s.value = 'м';
else if (s.name === 'item_type[]') s.value = 'fabric';
else if (s.name === 'item_category[]') s.value = 'Без категории';
else s.selectedIndex = 0;
});
const nci = newRow.querySelector('.new-category-input');
if (nci) {
nci.style.display = 'none';
nci.required = false;
nci.value = '';
}
const rb = newRow.querySelector('.remove-row-btn');
if (rb) rb.style.display = 'inline-block';
// Attach calculation events to new row
const quantityInput = newRow.querySelector('.quantity-input');
const perUnitInput = newRow.querySelector('.per-unit-input');
if (quantityInput && perUnitInput) {
quantityInput.addEventListener('input', () => calculateItemsFromFabric(newRow));
perUnitInput.addEventListener('input', () => calculateItemsFromFabric(newRow));
}
container.appendChild(newRow);
attachCategoryChangeEvent(newRow);
}
function removeRow(button) { const row = button.closest('.dynamic-row'); if (row) row.remove(); }
function handleCategoryChange(selectElement) { const row = selectElement.closest('.dynamic-row'); if (!row) return; const nci = row.querySelector('.new-category-input'); if (!nci) return; const isNew = selectElement.value === '__new__'; nci.style.display = isNew ? 'block' : 'none'; nci.required = isNew; if (!isNew) nci.value = ''; }
function attachCategoryChangeEvent(rowElement) { const cs = rowElement.querySelector('.category-select'); if(cs) { cs.removeEventListener('change', categoryChangeHandler); cs.addEventListener('change', categoryChangeHandler); } }
function categoryChangeHandler() { handleCategoryChange(this); }
document.addEventListener('DOMContentLoaded', () => {
// Initialize existing rows
document.querySelectorAll('.dynamic-row').forEach(row => {
attachCategoryChangeEvent(row);
const cs = row.querySelector('.category-select');
if (cs) handleCategoryChange(cs);
// Attach calculation events
const quantityInput = row.querySelector('.quantity-input');
const perUnitInput = row.querySelector('.per-unit-input');
if (quantityInput && perUnitInput) {
quantityInput.addEventListener('input', () => calculateItemsFromFabric(row));
perUnitInput.addEventListener('input', () => calculateItemsFromFabric(row));
// Calculate initial values
calculateItemsFromFabric(row);
}
});
// Add initial row if none exists
if (!document.querySelector('#material-rows .dynamic-row')) addRow();
});
</script>
"""
# Контент раскроя
CUTTING_CONTENT = """
<div class="card">
<div class="card-header"><i class="fas fa-cut"></i>Регистрация раскроя</div>
<div class="card-body">
<form method="POST" id="cutting-form">
<div class="row mb-3 align-items-end">
<div class="col-md-5">
<label for="fabric_id" class="form-label">Выберите ткань:</label>
<select id="fabric_id" name="fabric_id" class="form-select" required onchange="updateAvailableQuantity()">
<option value="">-- Выберите ткань --</option>
{% for fabric in fabrics %}
<option value="{{ fabric.id }}" data-unit="{{ fabric.unit }}" data-quantity="{{ fabric.quantity_str }}" data-items-per-unit="{{ fabric.items_per_unit }}">
{{ fabric.name }} ({{ fabric.category | default('Без категории') }}) - {{ fabric.quantity_str }} {{ fabric.unit }}
</option>
{% endfor %}
</select>
<div id="available-quantity" class="form-text text-primary fw-bold mt-1"></div>
<div id="items-per-unit" class="form-text text-success fw-bold mt-1"></div>
</div>
<div class="col-md-3">
<label for="cut_items_quantity" class="form-label">Кол-во раскр. изделий:</label>
<input type="number" id="cut_items_quantity" name="cut_items_quantity" class="form-control" min="1" step="1" required>
</div>
<div class="col-md-3">
<label for="fabric_used" class="form-label">Использ. ткани (<span id="fabric-unit">м</span>):</label>
<input type="text" id="fabric_used" name="fabric_used" class="form-control" placeholder="2,50" required inputmode="decimal">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-paper-plane me-1"></i> Ok</button>
</div>
</div>
</form>
</div>
</div>
"""
# Скрипты раскроя
CUTTING_SCRIPTS = """
<script>
function updateAvailableQuantity() {
const select = document.getElementById('fabric_id');
const selectedOption = select ? select.options[select.selectedIndex] : null;
const availableDiv = document.getElementById('available-quantity');
const itemsPerUnitDiv = document.getElementById('items-per-unit');
const unitSpan = document.getElementById('fabric-unit');
if (selectedOption && selectedOption.value) {
const quantity = selectedOption.getAttribute('data-quantity') || '0,00';
const unit = selectedOption.getAttribute('data-unit') || '?';
const materialPerUnit = parseFloat(selectedOption.getAttribute('data-material-per-unit')) || 0;
// Convert quantity from string "1 000,00" format to number
const qtyNum = parseFloat(quantity.replace(/\s/g, '').replace(',', '.')) || 0;
// Material per unit is already in correct format (e.g., 2.5 meters per 1 item)
// Calculate total items possible from all fabric
const totalPossibleItems = materialPerUnit > 0 ? Math.floor(qtyNum / materialPerUnit) : 0;
if(availableDiv) availableDiv.textContent = `В наличии: ${quantity} ${unit}`;
if(itemsPerUnitDiv) {
if (materialPerUnit > 0) {
itemsPerUnitDiv.innerHTML = `Расход материала на 1 изделие: ${materialPerUnit.toFixed(2)} ${unit}<br>`;
itemsPerUnitDiv.innerHTML += `Можно сделать всего: ${totalPossibleItems} шт.`;
} else {
itemsPerUnitDiv.textContent = 'Укажите расход материала на единицу изделия в закупе';
}
}
if(unitSpan) unitSpan.textContent = unit;
} else {
if(availableDiv) availableDiv.textContent = '';
if(itemsPerUnitDiv) itemsPerUnitDiv.textContent = '';
if(unitSpan) unitSpan.textContent = 'м';
}
}
document.addEventListener('DOMContentLoaded', () => { updateAvailableQuantity(); });
</script>
"""
# --- ИЗМЕНЕН: Контент пошива ---
SEWING_CONTENT = """
<div class="card">
<div class="card-header"><i class="fas fa-tshirt"></i>Регистрация пошива</div>
<div class="card-body">
{% if cutting_tasks %}
<form method="POST" id="sewing-form">
{# Выбор задания на раскрой и Название изделия #}
<div class="row mb-3">
<div class="col-md-6">
<label for="cutting_task_id" class="form-label">Выберите задание на раскрой:</label>
<select id="cutting_task_id" name="cutting_task_id" class="form-select" required onchange="showTaskDetails()">
<option value="">-- Выберите задание --</option>
{% for task in cutting_tasks %}
<option value="{{ task.id }}" data-fabric-name="{{ task.fabric_name }}" data-cut-quantity="{{ task.cut_items_quantity }}" data-fabric-used="{{ task.fabric_used_str }} {{ task.fabric_unit }}">
#{{ loop.index }} - {{ task.fabric_name }} - {{ task.cut_items_quantity }} ед. (Раскрой от {{ task.timestamp_created[:10] }})
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="sewn_product_name" class="form-label">Название сшитого изделия:</label>
<input type="text" id="sewn_product_name" name="sewn_product_name" class="form-control" required>
</div>
</div>
{# Детали задания на раскрой (БЕЗ ЦЕН/ЗП) #}
<div id="task-details" class="mb-3 p-3 border rounded bg-light" style="display: none;">
<h5>Детали задания на раскрой:</h5>
<div class="row">
<div class="col-md-6">
<p class="mb-1"><strong>Ткань:</strong> <span id="detail-fabric-name"></span></p>
<p class="mb-1"><strong>Раскроено:</strong> <span id="detail-cut-quantity"></span> ед.</p>
<p class="mb-1"><strong>Расход ткани (на все):</strong> <span id="detail-fabric-used"></span></p>
</div>
<div class="col-md-6"></div>
</div>
</div>
{# Количество сшитых #}
<div class="row mb-3">
<div class="col-md-4">
<label for="sewn_quantity" class="form-label">Количество сшитых изделий:</label>
<input type="number" id="sewn_quantity" name="sewn_quantity" class="form-control" min="1" step="1" required>
<div class="form-text text-danger fw-bold">Не больше, чем было раскроено (<span id="max-sewn-quantity">0</span>).</div>
</div>
</div>
<hr>
{# --- НАЧАЛО ДОБАВЛЕНИЯ: Добавление фурнитуры --- #}
<h6>Добавить фурнитуру для ЭТОГО пошива: <small class="text-muted">(Опционально)</small></h6>
<div id="fittings-rows">
<!-- Шаблон строки фурнитуры -->
<div class="row dynamic-fitting-row mb-2 align-items-center">
<div class="col-md-5">
<select name="fitting_ids[]" class="form-select form-select-sm fitting-select">
<option value="">-- Выберите фурнитуру --</option>
{% for fitting in fittings %} {# Используем переданный список фурнитуры #}
<option value="{{ fitting.id }}" data-quantity="{{ fitting.quantity_str }}" data-unit="{{ fitting.unit }}">
{{ fitting.name }} ({{ fitting.category | default('Без категории') }}) - {{ fitting.quantity_str }} {{ fitting.unit }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
{# Количество фурнитуры - целое число #}
<input type="number" name="fitting_quantities[]" class="form-control form-control-sm" placeholder="Кол-во (шт)" min="1" step="1" inputmode="numeric">
</div>
<div class="col-md-3 fitting-availability form-text text-info"></div>
<button type="button" class="btn btn-sm btn-danger remove-fitting-row-btn" onclick="removeFittingRow(this)" title="Удалить фурнитуру"><i class="fas fa-times"></i></button>
</div>
<!-- Конец шаблона -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2 mb-3" onclick="addFittingRow()"><i class="fas fa-plus"></i> Добавить фурнитуру</button>
{# --- КОНЕЦ ДОБАВЛЕНИЯ --- #}
<hr>
{# Регистрация брака #}
<h6>Регистрация брака материалов (если есть): <small class="text-muted">(Опционально)</small></h6>
<div id="defect-rows">
<!-- Шаблон строки брака -->
<div class="row dynamic-defect-row mb-2 align-items-center">
<div class="col-md-5">
<select name="defect_material_id[]" class="form-select form-select-sm defect-material-select">
<option value="">-- Выберите материал/фурнитуру для брака --</option>
{% for material in all_materials %}
{% set unit_display = material.unit %}
{% set type_display = 'Фурн.' if material.type == 'fittings' else 'Ткань' %}
<option value="{{ material.id }}" data-quantity="{{ material.quantity_str }}" data-unit="{{ unit_display }}" data-type="{{ material.type }}">
{{ material.name }} ({{ material.category | default('Без категории') }}) - {{ material.quantity_str }} {{ unit_display }} [{{ type_display }}]
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<input type="text" name="defect_quantity[]" class="form-control form-control-sm defect-quantity-input" placeholder="Количество">
</div>
<div class="col-md-3 defect-availability form-text text-info"></div>
<button type="button" class="btn btn-sm btn-danger remove-defect-row-btn" onclick="removeDefectRow(this)" title="Удалить брак"><i class="fas fa-times"></i></button>
</div>
<!-- Конец шаблона -->
</div>
<button type="button" class="btn btn-sm btn-outline-warning mt-2" onclick="addDefectRow()"><i class="fas fa-plus"></i> Добавить брак</button>
<hr>
<button type="submit" class="btn btn-primary mt-3"><i class="fas fa-check-circle me-1"></i>Зарегистрировать пошив</button>
</form>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>Нет заданий, ожидающих пошива.
</div>
{% endif %}
</div>
</div>
"""
# --- ИЗМЕНЕН: Скрипты пошива ---
SEWING_SCRIPTS = """
<script>
function showTaskDetails() {
const select = document.getElementById('cutting_task_id');
const selectedOption = select ? select.options[select.selectedIndex] : null;
const detailsDiv = document.getElementById('task-details');
const maxSewnSpan = document.getElementById('max-sewn-quantity');
const sewnInput = document.getElementById('sewn_quantity');
const productNameInput = document.getElementById('sewn_product_name');
if (selectedOption && selectedOption.value) {
const fabricName = selectedOption.getAttribute('data-fabric-name') || 'N/A';
const cutQuantity = selectedOption.getAttribute('data-cut-quantity') || '0';
const fabricUsed = selectedOption.getAttribute('data-fabric-used') || 'N/A';
const fields = {'detail-fabric-name': fabricName, 'detail-cut-quantity': cutQuantity, 'detail-fabric-used': fabricUsed };
for(const id in fields) { const element = document.getElementById(id); if(element) element.textContent = fields[id]; }
const costElem = document.getElementById('detail-material-cost'); if(costElem) costElem.textContent = '';
const salaryElem = document.getElementById('detail-cutting-salary'); if(salaryElem) salaryElem.textContent = '';
if(maxSewnSpan) maxSewnSpan.textContent = cutQuantity;
if(sewnInput) sewnInput.max = cutQuantity;
if (productNameInput && !productNameInput.value) productNameInput.value = fabricName; // Auto-fill product name
if(detailsDiv) detailsDiv.style.display = 'block';
} else {
if(detailsDiv) detailsDiv.style.display = 'none';
if(maxSewnSpan) maxSewnSpan.textContent = '0';
if(sewnInput) { sewnInput.max = ''; sewnInput.value = ''; }
if (productNameInput) productNameInput.value = '';
}
}
// --- Логика для фурнитуры ---
function addFittingRow() {
const container = document.getElementById('fittings-rows'); const firstRow = container.querySelector('.dynamic-fitting-row'); if (!firstRow) return;
const newRow = firstRow.cloneNode(true); newRow.querySelectorAll('select, input').forEach(el => el.value = '');
const availabilityDiv = newRow.querySelector('.fitting-availability'); if(availabilityDiv) availabilityDiv.textContent = '';
const removeBtn = newRow.querySelector('.remove-fitting-row-btn'); if(removeBtn) removeBtn.style.display = 'inline-block';
container.appendChild(newRow); attachFittingChangeEvent(newRow);
}
function removeFittingRow(button) { const row = button.closest('.dynamic-fitting-row'); if (row) row.remove(); }
function handleFittingChange(selectElement) {
const row = selectElement.closest('.dynamic-fitting-row'); if (!row) return;
const availabilityDiv = row.querySelector('.fitting-availability'); if (!availabilityDiv) return;
const selectedOption = selectElement.options[selectElement.selectedIndex];
if (selectedOption && selectedOption.value) {
const quantity = selectedOption.getAttribute('data-quantity') || '0';
const unit = selectedOption.getAttribute('data-unit') || 'шт';
availabilityDiv.textContent = `Доступно: ${quantity} ${unit}`;
} else { availabilityDiv.textContent = ''; }
}
function attachFittingChangeEvent(rowElement) {
const fittingSelect = rowElement.querySelector('.fitting-select');
if(fittingSelect) {
fittingSelect.removeEventListener('change', fittingChangeHandler); // Удаляем старый обработчик
fittingSelect.addEventListener('change', fittingChangeHandler); // Добавляем новый
}
}
function fittingChangeHandler() { handleFittingChange(this); }
// --- Конец логики для фурнитуры ---
// --- Логика для брака ---
function addDefectRow() {
const container = document.getElementById('defect-rows'); const firstRow = container.querySelector('.dynamic-defect-row'); if (!firstRow) return;
const newRow = firstRow.cloneNode(true); newRow.querySelectorAll('select, input').forEach(el => el.value = '');
const availabilityDiv = newRow.querySelector('.defect-availability'); if(availabilityDiv) availabilityDiv.textContent = '';
const qtyInput = newRow.querySelector('.defect-quantity-input'); if(qtyInput){ qtyInput.inputMode = 'text'; qtyInput.placeholder = 'Количество'; qtyInput.step = 'any'; }
const removeBtn = newRow.querySelector('.remove-defect-row-btn'); if(removeBtn) removeBtn.style.display = 'inline-block';
container.appendChild(newRow); attachDefectChangeEvent(newRow);
}
function removeDefectRow(button) { const row = button.closest('.dynamic-defect-row'); if(row) row.remove(); }
function handleDefectChange(selectElement) {
const row = selectElement.closest('.dynamic-defect-row'); if (!row) return;
const availabilityDiv = row.querySelector('.defect-availability'); const quantityInput = row.querySelector('.defect-quantity-input');
const selectedOption = selectElement.options[selectElement.selectedIndex]; if (!availabilityDiv || !quantityInput) return;
if (selectedOption && selectedOption.value) {
const quantity = selectedOption.getAttribute('data-quantity') || '0'; const unit = selectedOption.getAttribute('data-unit') || '?'; const type = selectedOption.getAttribute('data-type') || '?';
availabilityDiv.textContent = `Доступно: ${quantity} ${unit}`;
if (type === 'fabric') { quantityInput.inputMode = 'decimal'; quantityInput.placeholder = 'e.g., 0,5'; quantityInput.step = '0.01'; }
else { quantityInput.inputMode = 'numeric'; quantityInput.placeholder = 'Кол-во (шт)'; quantityInput.step = '1'; }
} else { availabilityDiv.textContent = ''; quantityInput.inputMode = 'text'; quantityInput.placeholder = 'Количество'; quantityInput.step = 'any'; }
}
function attachDefectChangeEvent(rowElement) {
const defectSelect = rowElement.querySelector('.defect-material-select');
if(defectSelect) {
defectSelect.removeEventListener('change', defectChangeHandler);
defectSelect.addEventListener('change', defectChangeHandler);
}
}
function defectChangeHandler() { handleDefectChange(this); }
// --- Конец логики для брака ---
document.addEventListener('DOMContentLoaded', () => {
showTaskDetails();
// Привязка обработчиков для существующих строк фурнитуры и брака
document.querySelectorAll('.dynamic-fitting-row').forEach(row => {
attachFittingChangeEvent(row);
const fittingSelect = row.querySelector('.fitting-select');
if (fittingSelect && fittingSelect.value) handleFittingChange(fittingSelect); // Обновляем инфо о доступности
});
document.querySelectorAll('.dynamic-defect-row').forEach(row => {
attachDefectChangeEvent(row);
const defectSelect = row.querySelector('.defect-material-select');
if (defectSelect && defectSelect.value) handleDefectChange(defectSelect); // Обновляем инфо о доступности
});
// Добавление первой пустой строки, если их нет
if (!document.querySelector('#fittings-rows .dynamic-fitting-row')) addFittingRow();
if (!document.querySelector('#defect-rows .dynamic-defect-row')) addDefectRow();
});
</script>
"""
# Контент ОТК
QC_PACKING_CONTENT = """
<div class="card">
<div class="card-header"><i class="fas fa-box-open"></i>ОТК и Упаковка готовых изделий</div>
<div class="card-body">
{% if sewing_tasks %}
<form method="POST" id="qc-form" onsubmit="return validateQcForm()">
<div class="row mb-3">
<div class="col-md-6">
<label for="sewing_task_id" class="form-label">Выберите сшитое изделие:</label>
<select id="sewing_task_id" name="sewing_task_id" class="form-select" required onchange="updateQcQuantities()">
<option value="">-- Выберите изделие --</option>
{% for task in sewing_tasks %}
<option value="{{ task.id }}" data-sewn-quantity="{{ task.sewn_quantity }}" data-remaining-quantity="{{ task.remaining_quantity }}">
#{{ loop.index }} - {{ task.product_name }} - Всего: {{ task.sewn_quantity }}, Осталось: {{ task.remaining_quantity }} (Пошив от {{ task.timestamp_created[:10] }})
</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="quantity_packed" class="form-label">Количество упаковано (прошло ОТК):</label>
<input type="number" id="quantity_packed" name="quantity_packed" class="form-control" min="0" step="1" required oninput="validateQcSum()">
</div>
<div class="col-md-4">
<label for="quantity_defective" class="form-label">Количество брака (на этом этапе):</label>
<input type="number" id="quantity_defective" name="quantity_defective" class="form-control" min="0" step="1" value="0" oninput="validateQcSum()">
</div>
<div class="col-md-4">
<label for="defect_reason" class="form-label">Причина брака (если есть):</label>
<input type="text" id="defect_reason" name="defect_reason" class="form-control" placeholder="Брак при ОТК/упаковке">
</div>
</div>
<div id="qc-total-info" class="form-text mb-3 fw-bold"></div>
<button type="submit" class="btn btn-primary mt-3"><i class="fas fa-check-double me-1"></i>Отправить</button>
</form>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>Нет изделий, ожидающих ОТК и упаковки.
</div>
{% endif %}
</div>
</div>
"""
# Скрипты ОТК
QC_PACKING_SCRIPTS = """
<script>
let maxAllowedQuantity = 0;
let totalSewnQuantity = 0;
function updateQcQuantities() {
const select = document.getElementById('sewing_task_id'); const selectedOption = select ? select.options[select.selectedIndex] : null;
const packedInput = document.getElementById('quantity_packed'); const defectiveInput = document.getElementById('quantity_defective'); const totalInfo = document.getElementById('qc-total-info');
if (selectedOption && selectedOption.value) {
totalSewnQuantity = parseInt(selectedOption.getAttribute('data-sewn-quantity')) || 0; maxAllowedQuantity = parseInt(selectedOption.getAttribute('data-remaining-quantity')) || 0;
if(packedInput) { packedInput.max = maxAllowedQuantity; packedInput.value = ''; }
if(defectiveInput) { defectiveInput.max = maxAllowedQuantity; defectiveInput.value = '0'; }
if(totalInfo) { totalInfo.textContent = `Всего сшито: ${totalSewnQuantity} ед. Осталось обработать: ${maxAllowedQuantity} ед. Сумма упак. и брака не должна превышать остаток.`; totalInfo.classList.remove('text-danger', 'text-success'); }
validateQcSum();
} else {
maxAllowedQuantity = 0; totalSewnQuantity = 0; if(packedInput) { packedInput.max = ''; packedInput.value = ''; }
if(defectiveInput) { defectiveInput.max = ''; defectiveInput.value = '0'; } if(totalInfo) totalInfo.textContent = 'Выберите задание на пошив.';
totalInfo.classList.remove('text-danger', 'text-success');
}
}
function validateQcSum() {
const packedInput = document.getElementById('quantity_packed'); const defectiveInput = document.getElementById('quantity_defective'); const totalInfo = document.getElementById('qc-total-info'); if (!packedInput || !defectiveInput || !totalInfo) return false;
const select = document.getElementById('sewing_task_id'); if (!select || !select.value) return false; // Не выбрано задание
const packed = parseInt(packedInput.value) || 0; const defective = parseInt(defectiveInput.value) || 0; const totalProcessed = packed + defective;
totalInfo.classList.remove('text-danger', 'text-success'); // Сброс стилей
if (maxAllowedQuantity === 0 && totalProcessed > 0) { // Если остаток 0, но что-то ввели
totalInfo.textContent = `Задание уже полностью обработано (${totalSewnQuantity} ед.). Нельзя добавить больше.`; totalInfo.classList.add('text-danger'); return false;
}
if (totalProcessed > maxAllowedQuantity) { // Если сумма превышает остаток
totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает ОСТАТОК (${maxAllowedQuantity})!`; totalInfo.classList.add('text-danger'); return false;
} else if (totalProcessed <= 0 && (packedInput.value || defectiveInput.value)) { // Если сумма 0 или меньше, но поля не пустые (ошибка ввода)
totalInfo.textContent = `Ошибка: Введите корректные положительные числа. Сумма (${totalProcessed}) некорректна.`; totalInfo.classList.add('text-danger'); return false;
} else if (totalProcessed == 0) { // Если поля пустые
totalInfo.textContent = `Всего сшито: ${totalSewnQuantity} ед. Осталось обработать: ${maxAllowedQuantity} ед. Укажите кол-во упак. или брака.`; return false; // Не ошибка, но форма невалидна
} else { // Сумма корректна
totalInfo.textContent = `Всего сшито: ${totalSewnQuantity} ед. Осталось обработать: ${maxAllowedQuantity} ед. Сумма (${totalProcessed}) корректна.`; totalInfo.classList.add('text-success'); return true;
}
}
function validateQcForm() {
const select = document.getElementById('sewing_task_id'); if (!select || !select.value) { alert('Пожалуйста, выберите задание на пошив.'); return false; }
const isValidSum = validateQcSum(); // Вызываем валидацию суммы
if (!isValidSum) { // Если сумма некорректна (включая случай > остатка или <= 0)
alert('Ошибка в количестве упакованных или бракованных изделий. Проверьте введенные значения и остаток по заданию.'); return false;
}
return true; // Все проверки пройдены
}
document.addEventListener('DOMContentLoaded', () => { updateQcQuantities(); });
</script>
"""
# Контент Базы Клиентов
CLIENTS_CONTENT = """
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-users me-2"></i>База клиентов</h1>
</div>
<!-- Форма добавления клиента -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-user-plus"></i>Добавить нового клиента</div>
<div class="card-body">
<form method="POST" action="{{ url_for('clients_panel') }}">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label for="client_name" class="form-label">Имя / Название организации <span class="text-danger">*</span></label>
<input type="text" id="client_name" name="client_name" class="form-control" required>
</div>
<div class="col-md-3">
<label for="client_phone" class="form-label">Номер телефона <span class="text-danger">*</span></label>
<input type="tel" id="client_phone" name="client_phone" class="form-control" required>
</div>
<div class="col-md-4">
<label for="client_address" class="form-label">Адрес</label>
<input type="text" id="client_address" name="client_address" class="form-control">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-success w-100"><i class="fas fa-plus"></i></button>
</div>
</div>
</form>
</div>
</div>
<!-- Список клиентов -->
<div class="card">
<div class="card-header"><i class="fas fa-list"></i>Список клиентов</div>
<div class="card-body">
<div class="mb-3">
<input type="text" id="client-search" class="form-control" placeholder="Поиск по имени, телефону или адресу...">
</div>
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered" id="clients-table"> {# Добавлен table-bordered #}
<thead>
<tr>
<th onclick="sortTable(0, 'clients-table')">ID <i class="fas fa-sort"></i></th>
<th onclick="sortTable(1, 'clients-table')">Имя / Организация <i class="fas fa-sort"></i></th>
<th onclick="sortTable(2, 'clients-table')">Телефон <i class="fas fa-sort"></i></th>
<th onclick="sortTable(3, 'clients-table')">Адрес <i class="fas fa-sort"></i></th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for client in clients %} {# Цикл по клиентам #}
<tr class="client-row" data-search="{{ client.name|lower }} {{ client.phone }} {{ client.address|default('')|lower }}">
<td title="{{ client.id }}"><small>{{ client.id[:8] }}...</small></td>
<td>{{ client.name }}</td>
<td>{{ client.phone }}</td>
<td>{{ client.address | default('<span class="text-muted">-</span>') | safe }}</td>
<td>
<button type="button" class="btn btn-sm btn-info" data-bs-toggle="modal" data-bs-target="#historyModal-{{ client.id }}" title="Посмотреть историю отправок">
<i class="fas fa-history"></i> История
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">Клиенты еще не добавлены.</td>
</tr>
{% endfor %} {# Конец цикла по клиентам #}
<tr class="no-result-row" style="display: none;"><td colspan="5" class="text-center text-muted"></td></tr> {# Строка для "не найдено" #}
</tbody>
</table>
</div>
</div>
</div>
<!-- Модальные окна для истории -->
{% for client in clients %} {# Цикл по клиентам для создания модальных окон #}
<div class="modal fade" id="historyModal-{{ client.id }}" tabindex="-1" aria-labelledby="historyModalLabel-{{ client.id }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="historyModalLabel-{{ client.id }}">История отправок: {{ client.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Используем проверку is iterable + not string на всякий случай #}
{% if client.history is iterable and client.history is not string and client.history %}
<ul class="list-group list-group-flush">
{# Используем уже отсортированный в Python список history #}
{% for record in client.history %} {# Цикл по записям истории #}
<li class="list-group-item">
{# Используем timestamp_dt, если он есть, иначе форматируем строку #}
<strong>Дата:</strong> {{ record.timestamp_dt.strftime('%Y-%m-%d %H:%M') if record.timestamp_dt else record.timestamp[:16]|replace('T',' ') }} <br>
<strong>ID отправки:</strong> <small>{{ record.get('shipment_id', 'N/A') }}</small><br> {# Добавлено .get #}
<strong>Товары:</strong>
{# Добавлена проверка на iterable и not string для record.items #}
{% if record.items is iterable and record.items is not string and record.items %}
<ul class="mb-0">
{% for item in record.items %} {# Цикл по товарам в записи #}
<li>{{ item.get('product_name', '?') }}: {{ item.get('quantity', '?') }} шт.</li> {# Добавлено .get #}
{% endfor %} {# Конец цикла по товарам #}
</ul>
{% else %}
<span class="text-muted small">(Нет данных о товарах)</span>
{% endif %}
<small>Исходный ID упаковки: {{ record.get('packed_item_id', 'N/A') }}</small> {# Добавлено .get #}
</li>
{% endfor %} {# Конец цикла по записям истории #}
</ul>
{% else %}
<p class="text-center text-muted">Для этого клиента еще не было отправок (или данные истории некорректны).</p>
{% endif %} {# Конец проверки наличия истории #}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
{% endfor %} {# Конец цикла по клиентам для модальных окон #}
"""
# Скрипты Базы Клиентов
CLIENTS_SCRIPTS = """
<script>
const searchInput = document.getElementById('client-search');
const tableBody = document.getElementById('clients-table')?.querySelector('tbody');
if (searchInput && tableBody) {
searchInput.addEventListener('input', function() {
const searchTerm = searchInput.value.toLowerCase().trim();
const rows = tableBody.querySelectorAll('tr.client-row');
const noResultRow = tableBody.querySelector('.no-result-row'); // Находим строку "не найдено"
let found = false;
rows.forEach(row => {
const searchData = row.dataset.search || '';
if (searchData.includes(searchTerm)) { row.style.display = ''; found = true; }
else { row.style.display = 'none'; }
});
// Показать/скрыть сообщение "не найдено"
if (!found && searchTerm !== '') {
if (noResultRow) {
noResultRow.querySelector('td').textContent = `Клиенты не найдены по запросу "${searchTerm}".`;
noResultRow.style.display = ''; // Показываем строку
}
} else if (noResultRow) { // Убираем строку, если поиск пуст или что-то найдено
noResultRow.style.display = 'none';
}
});
}
// Функция сортировки sortTable должна быть доступна глобально (например, из BASE_TEMPLATE)
document.addEventListener('DOMContentLoaded', function() {});
</script>
"""
# --- ИЗМЕНЕН: Контент Админ-панели ---
ADMIN_CONTENT = """
<h1><i class="fas fa-tachometer-alt me-2"></i>Админ-панель</h1>
<p class="lead">Обзор состояния производства и настройки</p>
<!-- Сводка -->
<div class="row mb-4">
<div class="col-lg col-md-6 mb-3"><div class="card text-white bg-primary h-100"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-boxes me-2"></i>Материалы</h5><p class="card-text display-6">{{ materials_count }}</p></div><small>позиций на складе (>0)</small></div></div></div>
<div class="col-lg col-md-6 mb-3"><div class="card text-dark bg-info h-100"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-cut me-2"></i>Ожидают пошива</h5><p class="card-text display-6">{{ pending_cutting_count }}</p></div><small>заданий раскроя</small></div></div></div>
<div class="col-lg col-md-6 mb-3"><div class="card text-dark bg-warning h-100"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-hourglass-half me-2"></i>Ожидают ОТК</h5><p class="card-text display-6">{{ pending_qc_count }}</p></div><small>заданий пошива ({{ format_integer_py(pending_qc_quantity) }} шт. осталось обработать)</small></div></div></div> {# Изменен текст #}
<div class="col-lg col-md-6 mb-3"><div class="card text-white bg-success h-100"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-check-double me-2"></i>Упаковано</h5><p class="card-text display-6">{{ format_integer_py(total_packed_count) }}</p></div><small>готовых изделий (ожидают отправки)</small></div></div></div>
<div class="col-lg col-md-6 mb-3"><div class="card text-white h-100" style="background-color: #6f42c1;"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-shipping-fast me-2"></i>Готово к отправке</h5><p class="card-text display-6">{{ format_integer_py(items_ready_ship_qty) }}</p></div><small>шт. в {{ items_ready_ship_count }} партиях</small></div></div></div>
<div class="col-lg col-md-6 mb-3"><div class="card text-white bg-danger h-100"><div class="card-body d-flex flex-column justify-content-between"><div><h5 class="card-title mb-2"><i class="fas fa-exclamation-triangle me-2"></i>Брак (Всего)</h5><p class="card-text fs-5 mb-1">Ткань: {{ total_defect_fabric_m }} м</p><p class="card-text fs-5 mb-1">Фурнитура: {{ total_defect_fittings_pcs }} шт.</p><p class="card-text fs-5 mb-1">Готовые изд.: {{ total_defect_finished_pcs }} шт.</p><p class="card-text fs-5 mb-1 fw-bold">Стоимость: {{ total_defect_cost }} сом</p></div><small>за все время</small></div></div></div>
</div>
<!-- Настройки ЗП и Маржи -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-coins"></i>Настройки зарплат и маржи</div>
<div class="card-body">
<form action="{{ url_for('update_config') }}" method="POST">
<div class="row">
<div class="col-md-3 mb-2">
<label for="salary_cutter" class="form-label">ЗП Раскройщика (сом/ед.):</label>
<input type="text" id="salary_cutter" name="salary_cutter" class="form-control form-control-sm" value="{{ config.salary_cutter_per_unit|string|replace('.', ',') }}" inputmode="decimal" required>
</div>
<div class="col-md-3 mb-2">
<label for="salary_sewer" class="form-label">ЗП Швеи (сом/изделие):</label>
<input type="text" id="salary_sewer" name="salary_sewer" class="form-control form-control-sm" value="{{ config.salary_sewer_per_unit|string|replace('.', ',') }}" inputmode="decimal" required>
</div>
<div class="col-md-3 mb-2">
<label for="salary_packer" class="form-label">ЗП Упаковщика (сом/изделие):</label>
<input type="text" id="salary_packer" name="salary_packer" class="form-control form-control-sm" value="{{ config.salary_packer_per_unit|string|replace('.', ',') }}" inputmode="decimal" required>
</div>
<div class="col-md-3 mb-2">
<label for="margin" class="form-label">Маржа (сом/изделие):</label>
<input type="text" id="margin" name="margin" class="form-control form-control-sm" value="{{ config.margin_per_item|string|replace('.', ',') }}" inputmode="decimal" required>
</div>
</div>
<button type="submit" class="btn btn-sm btn-primary mt-2"><i class="fas fa-save me-1"></i>Сохранить настройки</button>
</form>
</div>
</div>
<!-- Управление категориями -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-tags"></i>Управление категориями материалов</div>
<div class="card-body">
<div class="row"> <div class="col-md-6 mb-3 mb-md-0"> <h6>Добавить</h6> <form action="{{ url_for('add_category') }}" method="POST" class="d-flex"> <input type="text" name="new_category_name" class="form-control form-control-sm me-2" placeholder="Название" required> <button type="submit" class="btn btn-sm btn-success flex-shrink-0"><i class="fas fa-plus"></i></button> </form> </div> <div class="col-md-6"> <h6>Удалить</h6> {% if categories and categories|reject('equalto', 'Без категории')|list %} <form action="{{ url_for('delete_category') }}" method="POST" class="d-flex"> <select name="category_to_delete" class="form-select form-select-sm me-2" required> <option value="">-- Выберите --</option> {% for category in categories %} {% if category != 'Без категории' %} <option value="{{ category }}">{{ category }}</option> {% endif %} {% endfor %} </select> <button type="submit" class="btn btn-sm btn-danger flex-shrink-0" onclick="return confirm('Удалить категорию? Материалы перейдут в \\'Без категории\\'. Необратимо!')"><i class="fas fa-trash"></i></button> </form> {% else %} <p class="text-muted small mb-0">Нет категорий для удаления.</p> {% endif %} </div> </div> <hr> <h6>Существующие:</h6> {% if categories %} <ul class="list-inline mb-0"> {% for category in categories %} <li class="list-inline-item mb-1"><span class="badge bg-secondary p-2">{{ category }}</span></li> {% endfor %} </ul> {% else %} <p class="text-muted small mb-0">Нет категорий.</p> {% endif %}
</div>
</div>
<!-- Добавление доп расходов -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-file-invoice-dollar"></i>Дополнительные расходы</div>
<div class="card-body">
<form action="{{ url_for('add_expense') }}" method="POST" class="row g-3 align-items-end">
<div class="col-md-6">
<label for="expense_description" class="form-label">Описание расхода:</label>
<input type="text" id="expense_description" name="expense_description" class="form-control form-control-sm" required>
</div>
<div class="col-md-3">
<label for="expense_amount" class="form-label">Сумма (сом):</label>
<input type="text" id="expense_amount" name="expense_amount" class="form-control form-control-sm" placeholder="500,00" inputmode="decimal" required>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-sm btn-warning w-100"><i class="fas fa-plus-circle me-1"></i>Добавить расход</button>
</div>
</form>
<hr>
<h6>Журнал доп. расходов:</h6>
<div class="table-responsive">
<table class="table table-striped table-hover table-sm table-bordered"> {# Добавлен table-bordered #}
<thead><tr><th>ID</th><th>Описание</th><th>Сумма (сом)</th><th>Дата</th></tr></thead>
<tbody>
{% for expense in expenses|sort(attribute='timestamp', reverse=True) %} {# Сортировка по дате #}
<tr>
<td title="{{ expense.id }}"><small>{{ expense.id[:8] }}...</small></td>
<td>{{ expense.description }}</td>
<td>{{ format_currency_py(expense.amount) }}</td>
<td>{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">Дополнительные расходы еще не добавлялись.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Таблицы с данными (вкладки) -->
<ul class="nav nav-tabs mb-3" id="adminTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link" id="materials-tab" data-bs-toggle="tab" data-bs-target="#materials-content" type="button"><i class="fas fa-boxes me-1"></i>Склад ({{ materials_count }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="cutting-tab" data-bs-toggle="tab" data-bs-target="#cutting-content" type="button"><i class="fas fa-cut me-1"></i>Раскрой ({{ cutting_tasks|length }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="sewing-tab" data-bs-toggle="tab" data-bs-target="#sewing-content" type="button"><i class="fas fa-tshirt me-1"></i>Пошив ({{ sewing_tasks|length }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="packed-tab" data-bs-toggle="tab" data-bs-target="#packed-content" type="button"><i class="fas fa-box-open me-1"></i>Упаковано ({{ packed_items|length }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link active" id="dispatch-tab" data-bs-toggle="tab" data-bs-target="#dispatch-content" type="button"><i class="fas fa-shipping-fast me-1"></i>Отправка ({{ items_ready_ship_count }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="dordoi-history-tab" data-bs-toggle="tab" data-bs-target="#dordoi-history-content" type="button"><i class="fas fa-store me-1"></i>История Дордой ({{ dordoi_shipments|length }})</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="defects-tab" data-bs-toggle="tab" data-bs-target="#defects-content" type="button"><i class="fas fa-exclamation-triangle me-1"></i>Брак ({{ defect_log|length }})</button></li>
</ul>
<div class="tab-content" id="adminTabsContent">
<!-- Вкладка Материалы -->
<div class="tab-pane fade" id="materials-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-boxes"></i>Список материалов</div><div class="card-body">
<input type="text" id="material-search" class="form-control form-control-sm mb-3" placeholder="Поиск по названию или категории...">
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered" id="materials-table"><thead><tr> {# Добавлен table-bordered #}
<th onclick="sortTable(0, 'materials-table')">ID <i class="fas fa-sort"></i></th>
<th onclick="sortTable(1, 'materials-table')">Название <i class="fas fa-sort"></i></th>
<th onclick="sortTable(2, 'materials-table')">Категория <i class="fas fa-sort"></i></th>
<th onclick="sortTable(3, 'materials-table')">Тип <i class="fas fa-sort"></i></th>
<th onclick="sortTable(4, 'materials-table', true)">Кол-во <i class="fas fa-sort"></i></th>
<th>Ед.изм.</th>
<th onclick="sortTable(6, 'materials-table', true)">Цена/ед <i class="fas fa-sort"></i></th>
<th onclick="sortTable(7, 'materials-table', true)">На ед. <i class="fas fa-sort"></i></th>
<th onclick="sortTable(8, 'materials-table')">Добавлен <i class="fas fa-sort"></i></th>
<th onclick="sortTable(9, 'materials-table')">Обновлен <i class="fas fa-sort"></i></th>
</tr></thead><tbody>
{# ИЗМЕНЕНО: Убрана проверка {% if m.quantity > 0 %}, т.к. фильтрация теперь в Python #}
{% for m in materials|sort(attribute='name') %}
<tr class="material-row" data-name="{{ m.name|lower }}" data-category="{{ m.category|default('Без категории')|lower }}">
<td title="{{ m.id }}"><small>{{ m.id[:8] }}...</small></td><td>{{ m.name }}</td>
<td><span class="badge bg-info text-dark">{{ m.category | default('Без категории') }}</span></td>
<td><span class="badge {{ 'bg-primary' if m.type == 'fabric' else 'bg-secondary' }}">{{ 'Ткань' if m.type == 'fabric' else 'Фурнитура' }}</span></td>
<td data-sort="{{ m.quantity }}">{{ format_currency_py(m.quantity) if m.type == 'fabric' else format_integer_py(m.quantity) }}</td><td>{{ m.unit }}</td>
<td data-sort="{{ m.price_per_unit }}">{{ format_currency_py(m.price_per_unit) }}</td>
<td data-sort="{{ m.items_per_unit }}">{{ format_integer_py(m.items_per_unit) }}</td>
<td>{{ m.timestamp_added[:16] | replace('T', ' ') if m.timestamp_added else 'N/A' }}</td>
<td>{{ m.timestamp_last_updated[:16] | replace('T', ' ') if m.timestamp_last_updated else 'N/A' }}</td>
</tr>
{# ИЗМЕНЕНО: Проверка на пустой список materials (уже отфильтрованный) #}
{% else %}
<tr><td colspan="10" class="text-center text-muted">Нет материалов в наличии.</td></tr>
{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Вкладка Раскрой -->
<div class="tab-pane fade" id="cutting-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-cut"></i>Задания на раскрой</div><div class="card-body">
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>ID</th><th>Ткань</th><th>Кол-во</th><th>Расход</th><th>Статус</th><th>Создано</th><th>Завершено</th>
</tr></thead><tbody>
{% for task in cutting_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #}
<tr>
<td title="{{ task.id }}"><small>{{ task.id[:8] }}...</small></td>
<td>{{ task.fabric_name }}<small title="{{ task.fabric_id }}">({{ task.fabric_id[:6] }}...)</small></td>
<td>{{ format_integer_py(task.cut_items_quantity) }}</td>
<td>{{ format_currency_py(task.fabric_used) }} {{ task.fabric_unit }}</td>
<td><span class="{{ getStatusClass(task.status) }}">{{ getStatusText(task.status) }}</span></td>
<td>{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }}</td>
<td>{{ task.timestamp_completed[:16] | replace('T', ' ') if task.timestamp_completed else '-' }}</td>
</tr>{% else %}<tr><td colspan="7" class="text-center text-muted">Нет заданий на раскрой.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Вкладка Пошив -->
<div class="tab-pane fade" id="sewing-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-tshirt"></i>Задания на пошив</div><div class="card-body">
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>ID</th><th>Изделие</th><th>Сшито</th>
{# --- ИЗМЕНЕНО: Добавлен столбец для фурнитуры --- #}
<th>Исп. фурн.</th>
{# --- КОНЕЦ ИЗМЕНЕНИЯ --- #}
<th>Брак пошива</th><th>Статус</th><th>Упак./Брак ОТК</th><th>Создано</th><th>Завершено</th><th>Раскрой ID</th>
</tr></thead><tbody>
{% for task in sewing_tasks|sort(attribute='timestamp_created', reverse=True) %} {# Сортировка по дате создания #}
<tr>
<td title="{{ task.id }}"><small>{{ task.id[:8] }}...</small></td>
<td>{{ task.product_name }}</td><td>{{ format_integer_py(task.sewn_quantity) }}</td>
{# --- ИЗМЕНЕНО: Вывод данных фурнитуры --- #}
<td>
{% if task.fittings_consumed is iterable and task.fittings_consumed is not string %} {# Доп. проверка #}
<ul class="list-unstyled mb-0 small">
{% for f in task.fittings_consumed %}
{% if f is mapping %} {# Проверка, что f - словарь #}
<li title="ID: {{ f.get('fitting_id','?') }}">{{ f.get('fitting_name','?') }}: {{ format_integer_py(f.get('quantity_used',0)) }} шт. ({{format_currency_py(f.get('cost', '0'))}} сом)</li> {# Добавлен вывод стоимости #}
{% endif %}
{% endfor %}
{% if task.fittings_cost and task.fittings_cost != '0.00' %}
<li class="fw-bold border-top pt-1 mt-1">Итого: {{format_currency_py(task.fittings_cost)}} сом</li> {# Общая стоимость фурнитуры #}
{% endif %}
</ul>
{% else %}<span class="text-muted small">-</span>{% endif %}
</td>
{# --- КОНЕЦ ИЗМЕНЕНИЯ --- #}
<td>
{% if task.defects_reported is iterable and task.defects_reported is not string %} {# Доп. проверка #}
<ul class="list-unstyled mb-0 small text-danger">
{% for d in task.defects_reported %}
{% if d is mapping %} {# Проверка, что d - словарь #}
<li>{{ d.get('material_name','?') }}:
{% set dtype = d.get('type') %}
{% set dqty = d.get('quantity',0) %}
{% if dtype == 'fabric' %}{{ format_currency_py(dqty) }}
{% else %}{{ format_integer_py(dqty) }}
{% endif %} {{ d.get('unit','?') }}
</li>
{% endif %}
{% endfor %}
</ul>
{% else %}<span class="text-muted small">-</span>{% endif %}
</td>
<td><span class="{{ getStatusClass(task.status) }}">{{ getStatusText(task.status) }}</span></td>
<td><span class="text-success">{{ format_integer_py(task.qc_packed_quantity) }}</span> / <span class="text-danger">{{ format_integer_py(task.qc_defective_quantity) }}</span></td>
<td>{{ task.timestamp_created[:16] | replace('T', ' ') if task.timestamp_created else 'N/A' }}</td>
<td>{{ task.timestamp_completed[:16] | replace('T', ' ') if task.timestamp_completed else '-' }}</td>
<td title="{{ task.cutting_task_id }}"><small>{{ task.cutting_task_id[:8] }}...</small></td>
</tr>{% else %}<tr><td colspan="10" class="text-center text-muted">Нет заданий на пошив.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Вкладка Упакованные -->
<div class="tab-pane fade" id="packed-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-box-open"></i>Упакованные изделия (все)</div><div class="card-body">
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>ID</th><th>Название</th><th>Кол-во</th><th>Себест. (ед.)</th><th>Цена (ед.)</th><th>Общая себест.</th><th>Общая цена</th><th>Статус</th><th>Дата упак.</th><th>Детали отправки</th><th>Пошив ID</th>
</tr></thead><tbody>
{% for item in packed_items|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #}
{% set qty = item.quantity if item.quantity > 0 else 1 %} {# item.quantity уже int #}
{% set cost_per_item = item.packed_total_cost / qty if qty > 0 else 0 %} {# item.packed_total_cost уже Decimal #}
{% set price_per_item = item.packed_final_price / qty if qty > 0 else 0 %} {# item.packed_final_price уже Decimal #}
<tr>
<td title="{{ item.id }}"><small>{{ item.id[:8] }}...</small></td><td>{{ item.product_name }}</td><td>{{ format_integer_py(item.quantity) }}</td>
<td>{{ format_currency_py(cost_per_item) }}</td>
<td>{{ format_currency_py(price_per_item) }}</td>
<td>{{ format_currency_py(item.packed_total_cost) }}</td>
<td>{{ format_currency_py(item.packed_final_price) }}</td>
<td><span class="{{ getStatusClass(item.status) }}">{{ getStatusText(item.status) }}</span></td>
<td>{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}</td>
<td>
{% set details = item.shipment_details %}
{% if details is mapping %} {# Проверка что details это словарь #}
<small>
{{ details.get('timestamp')[:16] | replace('T',' ') if details.get('timestamp') else '' }}
{% if details.get('type') == 'client' %}
<br>Клиент: <span title="ID: {{ details.get('client_id', '?')}}">{{ details.get('client_name', 'N/A')}}</span>
{% elif details.get('type') == 'dor_doi_point' %}
<br>Назначение: {{ details.get('destination', 'Дордой') }}
{% endif %}
</small>
{% else %} {# ИЗМЕНЕНО: Показываем историю частичных отправок #}
{# Логика отображения истории частичных отправок, если она будет храниться здесь #}
{# Например, если добавить поле `partial_shipments`: #}
{# {% if item.partial_shipments %} <ul class="list-unstyled mb-0 small"> {% for ps in item.partial_shipments %} ... {% endfor %} </ul> {% else %} - {% endif %} #}
{# Пока просто оставим прочерк, т.к. история хранится отдельно #}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td title="{{ item.sewing_task_id }}"><small>{{ item.sewing_task_id[:8] }}...</small></td>
</tr>{% else %}<tr><td colspan="11" class="text-center text-muted">Нет упакованных изделий.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Вкладка Отправка -->
<div class="tab-pane fade show active" id="dispatch-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-shipping-fast"></i>Готово к отправке</div><div class="card-body">
{% if items_ready_to_ship %}
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"> {# Добавлен table-bordered #}
<thead><tr>
<th>ID</th><th>Изделие</th><th>В наличии</th><th>Себест.(общ)</th><th>Цена (общ)</th><th>Дата упак.</th><th>Действия</th>
</tr></thead><tbody>
{% for item in items_ready_to_ship|sort(attribute='timestamp_packed', reverse=True) %} {# Сортировка по дате упаковки #}
<tr>
<td title="{{ item.id }}"><small>{{ item.id[:8] }}...</small></td>
<td>{{ item.product_name }}</td>
<td>{{ format_integer_py(item.quantity) }}</td>
<td>{{ format_currency_py(item.packed_total_cost) }}</td>
<td>{{ format_currency_py(item.packed_final_price) }}</td>
<td>{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}</td>
<td>
{# --- ИЗМЕНЕНО: Добавлено поле количества, изменена структура формы --- #}
<form action="{{ url_for('dispatch_item') }}" method="POST" class="dispatch-form">
<input type="hidden" name="item_id" value="{{ item.id }}">
<div class="row gx-2 gy-1 align-items-center">
<div class="col-auto" style="min-width: 80px;">
<label for="dispatch-qty-{{ item.id }}" class="visually-hidden">Кол-во</label>
<input type="number" name="quantity_to_dispatch" id="dispatch-qty-{{ item.id }}"
class="form-control form-control-sm dispatch-quantity-input"
value="{{ item.quantity }}" min="1" max="{{ item.quantity }}" step="1" required
data-max="{{ item.quantity }}">
</div>
<div class="col-auto" style="min-width: 150px;">
<label for="dispatch-dest-{{ item.id }}" class="visually-hidden">Куда</label>
<select name="destination_type" id="dispatch-dest-{{ item.id }}" class="form-select form-select-sm destination-select" required>
<option value="">-- Куда --</option>
<option value="client">Клиенту</option>
<option value="dor_doi_point">На Дордой</option>
</select>
</div>
<div class="col-auto client-selector-div" style="display: none; min-width: 200px;">
<label for="dispatch-client-{{ item.id }}" class="visually-hidden">Клиент</label>
<select name="client_id" id="dispatch-client-{{ item.id }}" class="form-select form-select-sm client-select">
<option value="">-- Выберите клиента --</option>
{% for client in clients %} {# Используем clients из контекста #}
<option value="{{ client.id }}">{{ client.name }} ({{ client.phone }})</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary" title="Отметить как отправленное"><i class="fas fa-truck"></i> Отправить</button>
</div>
</div>
</form>
{# --- КОНЕЦ ИЗМЕНЕНИЯ --- #}
</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>Нет товаров, готовых к отправке.
</div>
{% endif %}
</div></div>
</div>
{# --- НАЧАЛО: Вкладка истории Дордоя --- #}
<div class="tab-pane fade" id="dordoi-history-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-store"></i>История отправок на Дордой</div><div class="card-body">
{% if dordoi_shipments %}
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"><thead><tr>
<th>ID Отправки</th><th>Дата</th><th>Товары</th><th>Исходный ID уп.</th>
</tr></thead><tbody>
{% for shipment in dordoi_shipments %} {# Уже отсортировано в Python #}
<tr>
<td title="{{ shipment.shipment_id }}"><small>{{ shipment.shipment_id[:8] }}...</small></td>
<td>{{ shipment.timestamp_dt.strftime('%Y-%m-%d %H:%M') if shipment.timestamp_dt else shipment.timestamp[:16]|replace('T',' ') }}</td>
<td>
{% if shipment.items is iterable and shipment.items is not string and shipment.items %}
<ul class="list-unstyled mb-0 small">
{% for item in shipment.items %}
<li>{{ item.get('product_name', '?') }}: {{ item.get('quantity', '?') }} шт.</li>
{% endfor %}
</ul>
{% else %}<span class="text-muted small">-</span>{% endif %}
</td>
<td title="{{ shipment.packed_item_id }}"><small>{{ shipment.packed_item_id[:8] }}...</small></td>
</tr>
{% endfor %}
</tbody></table></div>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>Нет записей об отправках на Дордой.
</div>
{% endif %}
</div></div>
</div>
{# --- КОНЕЦ: Вкладка истории Дордоя --- #}
<!-- Вкладка Брак -->
<div class="tab-pane fade" id="defects-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-exclamation-triangle"></i>Журнал брака</div><div class="card-body">
<div class="table-responsive"><table class="table table-striped table-hover table-sm table-bordered"> {# Добавлен table-bordered #}
<thead class="table-light"><tr>
<th>ID</th><th>Материал/Изделие</th><th>Тип</th><th>Кол-во</th><th>Ед.</th><th>Стоимость</th><th>Этап</th><th>Причина</th><th>Дата</th><th>Пошив ID</th>
</tr></thead><tbody>
{% for defect in defect_log|sort(attribute='timestamp', reverse=True) %} {# Сортировка по дате брака #}
<tr class="{% if defect.stage == 'qc_packing' %}table-warning{% elif defect.stage == 'sewing' %}table-danger{% endif %}">
<td title="{{ defect.log_id }}"><small>{{ defect.log_id[:8] }}...</small></td>
<td>{{ defect.material_name }} {% if defect.material_id %}<small title="{{ defect.material_id }}">({{ defect.material_id[:6] }}...)</small>{% endif %}</td>
<td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
<td>{{ defect.quantity_view }}</td> {# Используем поле _view #}
<td>{{ defect.unit }}</td>
<td>{{ format_currency_py(defect.cost_dec) }}</td> {# Используем поле _dec #}
<td><span class="badge {{ 'bg-warning text-dark' if defect.stage == 'qc_packing' else 'bg-danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td>
<td>{{ defect.reason | default('-') }}</td>
<td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
<td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
</tr>{% else %}<tr><td colspan="10" class="text-center text-muted">Записи о браке отсутствуют.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
</div>
"""
# Скрипты Админ-панели (ИЗМЕНЕН: Логика для формы отправки с количеством)
ADMIN_SCRIPTS = """
<script>
// --- Поиск по таблице материалов ---
const searchInputMaterials = document.getElementById('material-search');
const tableBodyMaterials = document.getElementById('materials-table')?.querySelector('tbody');
if (searchInputMaterials && tableBodyMaterials) {
searchInputMaterials.addEventListener('input', function() {
const searchTerm = searchInputMaterials.value.toLowerCase().trim();
const rows = tableBodyMaterials.querySelectorAll('tr.material-row');
rows.forEach(row => {
const name = row.dataset.name || '';
const category = row.dataset.category || '';
row.style.display = (name.includes(searchTerm) || category.includes(searchTerm)) ? '' : 'none';
});
});
}
// --- Сортировка таблиц (Глобальная функция уже в BASE_TEMPLATE) ---
// --- Логика для вкладки "Отправка" ---
function handleDestinationChange(selectElement) {
const form = selectElement.closest('.dispatch-form'); if (!form) return;
const clientSelectorDiv = form.querySelector('.client-selector-div'); const clientSelect = form.querySelector('.client-select');
if (!clientSelectorDiv || !clientSelect) return; // Добавлена проверка
if (selectElement.value === 'client') { clientSelectorDiv.style.display = 'block'; clientSelect.required = true; }
else { clientSelectorDiv.style.display = 'none'; clientSelect.required = false; clientSelect.value = ''; }
}
function validateDispatchForm(form) {
const quantityInput = form.querySelector('.dispatch-quantity-input');
const destinationSelect = form.querySelector('.destination-select');
const clientSelect = form.querySelector('.client-select');
if (!quantityInput || !destinationSelect) { console.error("Dispatch form elements not found!"); return false; }
const quantity = parseInt(quantityInput.value);
const maxQuantity = parseInt(quantityInput.dataset.max || quantityInput.max);
if (isNaN(quantity) || quantity <= 0) { alert('Укажите корректное положительное количество для отправки.'); quantityInput.focus(); return false; }
if (quantity > maxQuantity) { alert(`Нельзя отправить ${quantity} шт., так как в наличии только ${maxQuantity} шт.`); quantityInput.focus(); return false; }
if (!destinationSelect.value) { alert('Выберите назначение отправки.'); destinationSelect.focus(); return false; }
if (destinationSelect.value === 'client' && (!clientSelect || !clientSelect.value)) { alert('Выберите клиента для отправки.'); clientSelect.focus(); return false; }
let confirmMsg = `Вы уверены, что хотите отправить ${quantity} шт.`;
if (quantity < maxQuantity) { confirmMsg += ` (останется ${maxQuantity - quantity} шт.)? Это действие нельзя отменить.`; }
else { confirmMsg += ` (весь остаток)? Это действие нельзя отменить.`; }
if (!confirm(confirmMsg)) { return false; } return true;
}
// --- Активация табов Bootstrap и обработчики для формы отправки ---
document.addEventListener('DOMContentLoaded', function () {
// Активация табов Bootstrap
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button[data-bs-toggle="tab"]'));
triggerTabList.forEach(function (triggerEl) {
var tabInstance = bootstrap.Tab.getInstance(triggerEl) || new bootstrap.Tab(triggerEl);
triggerEl.addEventListener('click', function (event) { event.preventDefault(); tabInstance.show(); window.history.replaceState(null, null, window.location.pathname + window.location.search + triggerEl.getAttribute('data-bs-target')); });
});
// Логика восстановления активной вкладки по хешу URL
var hash = window.location.hash;
var defaultTabSelector = '#dispatch-content'; // Вкладка по умолчанию
const validAdminHashes = ['#materials-content', '#cutting-content', '#sewing-content', '#packed-content', '#dispatch-content', '#dordoi-history-content', '#defects-content']; // Добавлен хеш Дордоя
let tabActivated = false;
if(hash && validAdminHashes.includes(hash)) {
var tabEl = document.querySelector(`button[data-bs-target="${hash}"]`);
if(tabEl) { var tabInstance = bootstrap.Tab.getInstance(tabEl) || new bootstrap.Tab(tabEl); tabInstance.show(); tabActivated = true;}
}
// Если хеша нет или он невалидный/не для этой страницы, активируем вкладку по умолчанию
if (!tabActivated) {
var defaultTabEl = document.querySelector(`button[data-bs-target="${defaultTabSelector}"]`);
if (defaultTabEl) { var defaultTabInstance = bootstrap.Tab.getInstance(defaultTabEl) || new bootstrap.Tab(defaultTabEl); defaultTabInstance.show(); window.history.replaceState(null, null, window.location.pathname + window.location.search + defaultTabSelector); } // Обновляем хеш на дефолтный
}
// Навешиваем обработчики на все формы отправки на вкладке "Отправка"
const dispatchContent = document.getElementById('dispatch-content');
if (dispatchContent) {
dispatchContent.querySelectorAll('.dispatch-form .destination-select').forEach(select => {
select.addEventListener('change', function() { handleDestinationChange(this); });
handleDestinationChange(select); // Инициализация при загрузке
});
dispatchContent.querySelectorAll('.dispatch-form').forEach(form => {
form.addEventListener('submit', function(event) {
if (!validateDispatchForm(this)) { event.preventDefault(); }
});
});
// Добавим обработчик для поля количества, чтобы не вводить больше максимума
dispatchContent.querySelectorAll('.dispatch-quantity-input').forEach(input => {
input.addEventListener('input', function() {
const max = parseInt(this.dataset.max || this.max);
let value = parseInt(this.value);
if (isNaN(value)) value = 0;
if (value < 1) this.value = 1;
if (value > max) this.value = max;
});
});
} else {
console.warn("Dispatch content tab not found, event listeners not attached.");
}
});
</script>
"""
# Контент Отчетов
REPORTS_CONTENT = """
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-chart-line me-2"></i>Отчеты</h1>
</div>
<!-- Фильтр -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-filter me-1"></i>Фильтр периода</div>
<div class="card-body">
<form method="GET" action="{{ url_for('reports') }}" id="report-filter-form">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label for="filter_type" class="form-label">Период:</label>
<select id="filter_type" name="filter" class="form-select form-select-sm">
<option value="month" {% if report.filter_type == 'month' %}selected{% endif %}>Этот месяц</option>
<option value="week" {% if report.filter_type == 'week' %}selected{% endif %}>Эта неделя</option>
<option value="day" {% if report.filter_type == 'day' %}selected{% endif %}>Конкретный день</option>
<option value="year" {% if report.filter_type == 'year' %}selected{% endif %}>Конкретный год</option>
<option value="custom" {% if report.filter_type == 'custom' %}selected{% endif %}>Произвольный</option>
</select>
</div>
<div class="col-md-3" id="date-selector-day" style="display: {% if report.filter_type == 'day' %}block{% else %}none{% endif %};">
<label for="filter_date_day" class="form-label">Дата:</label>
<input type="text"
id="filter_date_day" name="date" class="form-control form-control-sm flatpickr-date" value="{{ report.filter_values.get('date', report.current_day) }}">
</div>
<div class="col-md-3" id="date-selector-month" style="display: {% if report.filter_type == 'month' %}block{% else %}none{% endif %};">
<label for="filter_date_month" class="form-label">Месяц:</label>
<input type="month" id="filter_date_month" name="month" class="form-control form-control-sm" value="{{ report.filter_values.get('month', report.current_month) }}">
</div>
<div class="col-md-3" id="date-selector-year" style="display: {% if report.filter_type == 'year' %}block{% else %}none{% endif %};">
<label for="filter_date_year" class="form-label">Год:</label>
<input type="number" id="filter_date_year" name="year" class="form-control form-control-sm" value="{{ report.filter_values.get('year', report.current_year) }}" min="2020" max="2099" step="1">
</div>
<div class="col-md-3" id="custom-start-date" style="display: {% if report.filter_type == 'custom' %}block{% else %}none{% endif %};">
<label for="start_date" class="form-label">Начало:</label>
<input type="text" id="start_date" name="start_date" class="form-control form-control-sm flatpickr-date" value="{{ report.start_date }}">
</div>
<div class="col-md-3" id="custom-end-date" style="display: {% if report.filter_type == 'custom' %}block{% else %}none{% endif %};">
<label for="end_date" class="form-label">Конец:</label>
<input type="text" id="end_date" name="end_date" class="form-control form-control-sm flatpickr-date" value="{{ report.end_date }}">
</div>
<div class="col-md-auto">
<button type="submit" class="btn btn-sm btn-primary w-100"><i class="fas fa-search me-1"></i>Показать</button>
</div>
</div>
</form>
</div>
</div>
<!-- Сводка по периоду -->
<h4>Отчет за период: {{ report.start_date }} - {{ report.end_date }}</h4>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-4 mb-4">
<div class="col">
<div class="card h-100 text-white bg-success">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-shopping-bag me-2"></i>Выручка</h5>
<p class="card-text display-6">{{ format_currency_py(report.total_revenue) }}</p>
<small>сом ({{ format_integer_py(report.total_packed_qty) }} шт. упаковано в период)</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 text-dark bg-warning">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-dollar-sign me-2"></i>Прибыль</h5>
<p class="card-text display-6">{{ format_currency_py(report.total_profit) }}</p>
<small>сом (Выручка - Затраты)</small>
</div>
</div>
</div>
<div class="col">
{# --- ИЗМЕНЕНО: Карточка Общих Затрат стала кликабельной --- #}
<div class="card h-100 text-white bg-secondary clickable-card" data-bs-toggle="modal" data-bs-target="#costBreakdownModal" title="Нажмите для просмотра детализации затрат">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-money-bill-wave me-2"></i>Затраты (Общие)</h5>
<p class="card-text display-6">{{ format_currency_py(report.total_overall_cost) }}</p>
<small>сом (Себест.Упак.+Брак+Доп.) <span class="text-white-50">(Нажмите для деталей)</span></small>
</div>
</div>
{# --- КОНЕЦ ИЗМЕНЕНИЯ --- #}
</div>
<div class="col">
<div class="card h-100 text-white bg-danger">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-exclamation-triangle me-2"></i>Затраты на брак</h5>
<p class="card-text display-6">{{ format_currency_py(report.total_defect_cost) }}</p>
<small>сом ({{ report.filtered_defects|length }} записей)</small>
</div>
</div>
</div>
<div class="col">
{# --- ИЗМЕНЕНО: Карточка ЗП стала кликабельной --- #}
<div class="card h-100 text-dark bg-light clickable-card" data-bs-toggle="modal" data-bs-target="#salaryBreakdownModal" title="Нажмите для просмотра детализации ЗП">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-users-cog me-2"></i>ЗП (Упакованные)</h5>
<p class="card-text fs-5 mb-1">Материалы: {{ format_currency_py(report.total_material_cost) }} сом</p>
<p class="card-text fs-5 mb-1">Зарплаты: {{ format_currency_py(report.total_salary_cost) }} сом</p>
<p class="card-text fs-4 fw-bold mt-2">Итого себестоимость: {{ format_currency_py(report.total_cost_packed) }} сом</p>
<small>За {{ format_integer_py(report.total_packed_qty) }} шт. <span class="text-primary">(Нажмите для деталей ЗП)</span></small>
</div>
</div>
{# --- КОНЕЦ ИЗМЕНЕНИЯ --- #}
</div>
<div class="col">
<div class="card h-100 text-dark bg-info">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-file-invoice-dollar me-2"></i>Доп. расходы</h5>
<p class="card-text display-6">{{ format_currency_py(report.total_expenses) }}</p>
<small>сом ({{ report.filtered_expenses|length }} записей)</small>
</div>
</div>
</div>
</div>
<!-- Детализация -->
<ul class="nav nav-tabs mb-3" id="reportDetailsTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" id="prod-summary-tab" data-bs-toggle="tab" data-bs-target="#prod-summary-content" type="button"><i class="fas fa-tags me-1"></i>Сводка по продуктам</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="packed-items-tab" data-bs-toggle="tab" data-bs-target="#packed-items-content" type="button"><i class="fas fa-check-double me-1"></i>Упакованные изделия</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="defects-report-tab" data-bs-toggle="tab" data-bs-target="#defects-report-content" type="button"><i class="fas fa-exclamation-triangle me-1"></i>Брак за период</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="expenses-report-tab" data-bs-toggle="tab" data-bs-target="#expenses-report-content" type="button"><i class="fas fa-file-invoice-dollar me-1"></i>Доп. расходы за период</button></li>
</ul>
<div class="tab-content" id="reportDetailsTabsContent">
<!-- Сводка по продуктам -->
<div class="tab-pane fade show active" id="prod-summary-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-tags"></i>Сводка по продуктам за период (по дате упаковки)</div><div class="card-body bg-white">
<div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>Продукт</th><th>Упаковано (шт)</th><th>Выручка (сом)</th><th>Себестоимость (сом)</th><th>Прибыль (сом)</th><th>Средняя прибыль/шт</th>
</tr></thead><tbody>
{% for name, summary in report.production_summary.items() %}
{% set avg_profit = (summary.profit / summary.quantity) if summary.quantity > 0 else 0 %}
<tr>
<td>{{ name }}</td><td>{{ format_integer_py(summary.quantity) }}</td>
<td>{{ format_currency_py(summary.revenue) }}</td>
<td>{{ format_currency_py(summary.cost) }}</td>
<td>{{ format_currency_py(summary.profit) }}</td>
<td>{{ format_currency_py(avg_profit) }}</td>
</tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">Нет данных об упакованных товарах за этот период.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Упакованные изделия -->
<div class="tab-pane fade" id="packed-items-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-check-double"></i>Упакованные изделия за период</div><div class="card-body bg-white">
<div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>ID</th><th>Название</th><th>Кол-во</th><th>Себест. (ед.)</th><th>Цена (ед.)</th><th>Общ. себест.</th><th>Общ. цена</th><th>Дата упак.</th><th>Статус</th><th>Детали отправки</th><th>Пошив ID</th>
</tr></thead><tbody>
{% for item in report.filtered_packed_items|sort(attribute='timestamp_packed', reverse=True) %}
{% set qty = item.quantity if item.quantity > 0 else 1 %}
{% set cost_per_item = item.packed_total_cost / qty if qty > 0 else 0 %}
{% set price_per_item = item.packed_final_price / qty if qty > 0 else 0 %}
<tr>
<td title="{{ item.id }}"><small>{{ item.id[:8] }}...</small></td><td>{{ item.product_name }}</td><td>{{ format_integer_py(item.quantity) }}</td>
<td>{{ format_currency_py(cost_per_item) }}</td>
<td>{{ format_currency_py(price_per_item) }}</td>
<td>{{ format_currency_py(item.packed_total_cost) }}</td>
<td>{{ format_currency_py(item.packed_final_price) }}</td>
<td>{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}</td>
<td><span class="{{ getStatusClass(item.status) }}">{{ getStatusText(item.status) }}</span></td>
<td>
{% set details = item.shipment_details %}
{% if details is mapping %} {# Проверка что details это словарь #}
<small>
{# Используем поле _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' %}
<br>Клиент: <span title="ID: {{ details.get('client_id', '?')}}">{{ details.get('client_name', 'N/A')}}</span>
{% elif details.get('type') == 'dor_doi_point' %}
<br>Назначение: {{ details.get('destination', 'Дордой') }}
{% endif %}
</small>
{% elif item.status == 'packed_ready_to_ship' %} <span class="text-muted small">-</span> {# Готово, но не отправлено #}
{% else %} <span class="text-muted small">-</span> {# Другой статус или нет данных #} {% endif %}
</td>
<td title="{{ item.sewing_task_id }}"><small>{{ item.sewing_task_id[:8] }}...</small></td>
</tr>{% else %}<tr><td colspan="11" class="text-center text-muted">Нет упакованных изделий за этот период.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Брак -->
<div class="tab-pane fade" id="defects-report-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-exclamation-triangle"></i>Брак за период</div><div class="card-body bg-white">
<div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr>
<th>ID</th><th>Материал/Изделие</th><th>Тип</th><th>Кол-во</th><th>Ед.</th><th>Стоимость</th><th>Этап</th><th>Причина</th><th>Дата</th><th>Пошив ID</th>
</tr></thead><tbody>
{% for defect in report.filtered_defects|sort(attribute='timestamp', reverse=True) %}
<tr class="{% if defect.stage == 'qc_packing' %}table-warning{% elif defect.stage == 'sewing' %}table-danger{% endif %}">
<td title="{{ defect.log_id }}"><small>{{ defect.log_id[:8] }}...</small></td>
<td>{{ defect.material_name }} {% if defect.material_id %}<small title="{{ defect.material_id }}">({{ defect.material_id[:6] }}...)</small>{% endif %}</td>
<td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
<td>{{ defect.quantity_view }}</td><td>{{ defect.unit }}</td>
<td>{{ format_currency_py(defect.cost_dec) }}</td>
<td><span class="badge {{ 'bg-warning text-dark' if defect.stage == 'qc_packing' else 'bg-danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td>
<td>{{ defect.reason | default('-') }}</td>
<td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
<td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
</tr>{% else %}<tr><td colspan="10" class="text-center text-muted">Нет записей о браке за этот период.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
<!-- Доп. Расходы -->
<div class="tab-pane fade" id="expenses-report-content" role="tabpanel">
<div class="card"><div class="card-header"><i class="fas fa-file-invoice-dollar"></i>Доп. расходы за период</div><div class="card-body bg-white"> {# Добавлен bg-white #}
<div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr> {# Добавлен table-bordered #}
<th>ID</th><th>Описание</th><th>Сумма (сом)</th><th>Дата</th>
</tr></thead><tbody>
{% for expense in report.filtered_expenses|sort(attribute='timestamp', reverse=True) %}
<tr>
<td title="{{ expense.id }}"><small>{{ expense.id[:8] }}...</small></td><td>{{ expense.description }}</td>
<td>{{ format_currency_py(expense.amount) }}</td>
<td>{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }}</td>
</tr>{% else %}<tr><td colspan="4" class="text-center text-muted">Нет доп. расходов за этот период.</td></tr>{% endfor %}
</tbody></table></div></div></div>
</div>
</div>
{# --- Модальное окно для детализации ЗП --- #}
<div class="modal fade" id="salaryBreakdownModal" tabindex="-1" aria-labelledby="salaryBreakdownModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="salaryBreakdownModalLabel">Детализация зарплат за период ({{ report.start_date }} - {{ report.end_date }})</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
ЗП Раскройщиков:
<span class="badge bg-info rounded-pill">{{ format_currency_py(report.total_cutter_salary) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
ЗП Швей:
<span class="badge bg-warning rounded-pill text-dark">{{ format_currency_py(report.total_sewer_salary) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
ЗП Упаковщиков:
<span class="badge bg-success rounded-pill">{{ format_currency_py(report.total_packer_salary) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center fw-bold">
Итого ЗП (Упакованные):
<span class="badge bg-primary rounded-pill">{{ format_currency_py(report.total_salary_cost) }} сом</span>
</li>
</ul>
<small class="d-block text-muted mt-2">Примечание: Расчет ЗП по этапам основан на количестве фактически упакованных изделий в выбранный период и текущих ставках ЗП из настроек.</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
{# --- КОНЕЦ: Модальное окно для детализации ЗП --- #}
{# --- НАЧАЛО: Модальное окно для детализации Затрат --- #}
<div class="modal fade" id="costBreakdownModal" tabindex="-1" aria-labelledby="costBreakdownModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="costBreakdownModalLabel">Детализация общих затрат за период ({{ report.start_date }} - {{ report.end_date }})</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Себестоимость упакованных:
<span class="badge bg-light text-dark rounded-pill">{{ format_currency_py(report.total_cost_packed) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Стоимость брака:
<span class="badge bg-danger rounded-pill">{{ format_currency_py(report.total_defect_cost) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Дополнительные расходы:
<span class="badge bg-info rounded-pill">{{ format_currency_py(report.total_expenses) }} сом</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center fw-bold">
Итого общие затраты:
<span class="badge bg-secondary rounded-pill">{{ format_currency_py(report.total_overall_cost) }} сом</span>
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
{# --- КОНЕЦ: Модальное окно для детализации Затрат --- #}
"""
# Скрипты Отчетов
REPORTS_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', function () {
flatpickr(".flatpickr-date", { dateFormat: "Y-m-d", locale: "ru" });
const filterTypeSelect = document.getElementById('filter_type'); const customStartDate = document.getElementById('custom-start-date');
const customEndDate = document.getElementById('custom-end-date'); const daySelector = document.getElementById('date-selector-day');
const monthSelector = document.getElementById('date-selector-month'); const yearSelector = document.getElementById('date-selector-year');
function toggleDateSelectors() {
const selectedType = filterTypeSelect.value; customStartDate.style.display = 'none'; customEndDate.style.display = 'none';
daySelector.style.display = 'none'; monthSelector.style.display = 'none'; yearSelector.style.display = 'none';
if (selectedType === 'custom') { customStartDate.style.display = 'block'; customEndDate.style.display = 'block'; }
else if (selectedType === 'day') { daySelector.style.display = 'block'; } else if (selectedType === 'month') { monthSelector.style.display = 'block'; }
else if (selectedType === 'year') { yearSelector.style.display = 'block'; }
if (selectedType !== 'custom') { document.getElementById('start_date').value = ''; document.getElementById('end_date').value = ''; }
if (selectedType !== 'day') { const dp = document.getElementById('filter_date_day')._flatpickr; if(dp) dp.clear(); }
if (selectedType !== 'month') { document.getElementById('filter_date_month').value = ''; }
}
if (filterTypeSelect) { filterTypeSelect.addEventListener('change', toggleDateSelectors); toggleDateSelectors(); }
var triggerTabList = [].slice.call(document.querySelectorAll('#reportDetailsTabs button[data-bs-toggle="tab"]'));
triggerTabList.forEach(function (triggerEl) { var tabInstance = bootstrap.Tab.getInstance(triggerEl) || new bootstrap.Tab(triggerEl); triggerEl.addEventListener('click', function (event) { event.preventDefault(); tabInstance.show(); window.history.replaceState(null, null, window.location.pathname + window.location.search + triggerEl.getAttribute('data-bs-target')); }); });
const validReportHashes = ['#prod-summary-content', '#packed-items-content', '#defects-report-content', '#expenses-report-content'];
let reportHash = window.location.hash; let reportTabActivated = false;
if(reportHash && validReportHashes.includes(reportHash) ) { var reportTabEl = document.querySelector(`button[data-bs-target="${reportHash}"]`); if(reportTabEl) { var reportTabInstance = bootstrap.Tab.getInstance(reportTabEl) || new bootstrap.Tab(reportTabEl); reportTabInstance.show(); reportTabActivated = true; } }
if (!reportTabActivated) { var defaultReportTabEl = document.getElementById('prod-summary-tab'); if (defaultTabEl) { var defaultReportTabInstance = bootstrap.Tab.getInstance(defaultTabEl) || new bootstrap.Tab(defaultTabEl); defaultReportTabInstance.show(); } }
});
</script>
"""
# Контент Облака
CLOUD_CONTENT = """
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-cloud me-2"></i>Облачное хранилище</h1>
</div>
<div class="card mb-4">
<div class="card-header"><i class="fas fa-upload"></i>Загрузить файл</div>
<div class="card-body">
<form method="POST" action="{{ url_for('cloud_storage') }}" enctype="multipart/form-data">
<div class="mb-3">
<label for="fileDescription" class="form-label">Описание файла (необязательно):</label>
<input type="text" class="form-control" id="fileDescription" name="description" placeholder="Описание">
</div>
<div class="mb-3">
<label for="fileUpload" class="form-label">Выберите файл для загрузки:</label>
<input class="form-control" type="file" id="fileUpload" name="file" required>
<div class="form-text">Допустимые форматы: png, jpg, jpeg, gif, pdf, txt, doc, docx, xls, xlsx. Макс. размер: 16MB.</div>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-upload me-1"></i>Загрузить</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><i class="fas fa-file-alt"></i>Список файлов</div>
<div class="card-body">
<div class="mb-3">
<input type="search" class="form-control" id="cloud-search" placeholder="Поиск по описанию или имени файла..." value="{{ search_query }}">
</div>
{% if files %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
{% for file in files %}
<div class="col">
<div class="card clickable-card">
{% if file.thumbnail_filename %}
<img src="{{ url_for('get_thumbnail', filename=file.thumbnail_filename) }}" class="card-img-top cloud-thumbnail" alt="Миниатюра">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light" style="min-height: 100px;">
<i class="fas fa-file fa-3x text-muted"></i> {# Иконка для не-изображений #}
</div>
{% endif %}
<div class="card-body">
<h6 class="card-title">
<a href="{{ url_for('download_file', filename=file.stored_filename) }}" title="Скачать {{ file.original_filename }}" class="stretched-link text-decoration-none text-dark">
{{ file.original_filename }}
</a>
</h6>
{% if file.description %}
<p class="card-text"><small class="text-muted">{{ file.description }}</small></p>
{% endif %}
<p class="card-text"><small class="text-muted">Размер: {{ (file.size/1024)|round(1) }} КБ</small></p>
<p class="card-text"><small class="text-muted">Загружен: {{ file.timestamp[:10] }}</p>
<div class="d-flex justify-content-end mt-2">
<form method="post" action="{{ url_for('delete_cloud_file', file_id=file.file_id) }}" onsubmit="return confirm('Удалить файл \\'{{ file.original_filename }}\\'?')">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i> Удалить</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>Файлы еще не загружены.
</div>
{% endif %}
</div>
</div>
"""
# Скрипты Облака
CLOUD_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', function () {
const searchInputCloud = document.getElementById('cloud-search');
const fileCards = document.querySelectorAll('.card.clickable-card');
if (searchInputCloud) {
searchInputCloud.addEventListener('input', function() {
const searchTerm = searchInputCloud.value.toLowerCase().trim();
fileCards.forEach(card => {
const cardText = (card.textContent || card.innerText).toLowerCase();
card.style.display = searchTerm ? (cardText.includes(searchTerm) ? '' : 'none') : '';
});
});
}
});
</script>
"""
# Контент Авансов
ADVANCES_CONTENT = """
<div class="card mb-4">
<div class="card-header"><i class="fas fa-money-bill-wave"></i>Выдача аванса</div>
<div class="card-body">
<form method="POST" action="{{ url_for('advances') }}">
<div class="row g-3">
<div class="col-md-4">
<label for="employee_name" class="form-label">Имя сотрудника:</label>
<input type="text" id="employee_name" name="employee_name" class="form-control" required>
</div>
<div class="col-md-4">
<label for="role" class="form-label">Должность:</label>
<select id="role" name="role" class="form-select" required>
<option value="">Выберите должность</option>
<option value="cutter">Раскройщик</option>
<option value="sewer">Швея</option>
<option value="packer">Упаковщик</option>
</select>
</div>
<div class="col-md-4">
<label for="amount" class="form-label">Сумма аванса (сом):</label>
<input type="text" id="amount" name="amount" class="form-control" required inputmode="decimal">
</div>
</div>
<button type="submit" class="btn btn-primary mt-3">
<i class="fas fa-hand-holding-usd me-1"></i>Выдать аванс
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><i class="fas fa-list"></i>История авансов</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th>Дата выдачи</th>
<th>Сотрудник</th>
<th>Должность</th>
<th>Сумма</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for advance in advances %}
<tr>
<td>{{ advance.timestamp[:16]|replace('T', ' ') }}</td>
<td>{{ advance.employee_name }}</td>
<td>
{% if advance.role == 'cutter' %}Раскройщик
{% elif advance.role == 'sewer' %}Швея
{% elif advance.role == 'packer' %}Упаковщик
{% else %}{{ advance.role }}{% endif %}
</td>
<td>{{ format_currency_py(advance.amount) }}</td>
<td>
<span class="badge {% if advance.is_processed %}bg-success{% else %}bg-warning{% endif %}">
{% if advance.is_processed %}Вычтено из ЗП{% else %}Ожидает вычета{% endif %}
</span>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">История авансов пуста</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
ADVANCES_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add any needed JavaScript for the advances page
});
</script>
"""
# --- Конец HTML Шаблонов ---
# Добавляем утилиты в контекст Jinja
@app.context_processor
def inject_utils():
return {
'get_current_time': get_current_time,
'getStatusText': getStatusText,
'getStatusClass': getStatusClass,
'format_currency_py': format_currency_py,
'format_integer_py': format_integer_py
}
if __name__ == '__main__':
# Запускаем поток для периодического бэкапа
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
try:
logging.info("Первоначальная загрузка данных...")
load_data()
load_client_data()
logging.info("Данные успешно загружены/инициализированы.")
except Exception as e:
logging.critical(f"Не удалось загрузить базы данных при запуске: {e}", exc_info=True)
logging.info("Запуск Flask приложения на http://0.0.0.0:7860")
app.run(debug=True, host='0.0.0.0', port=7860, use_reloader=False)