from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify import json import os import logging import threading import time from datetime import datetime from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError from werkzeug.utils import secure_filename from dotenv import load_dotenv import requests import uuid from collections import Counter load_dotenv() app = Flask(__name__) app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890_no_login' # Replace with a strong, unique key DATA_FILE = 'data.json' SYNC_FILES = [DATA_FILE] REPO_ID = "Kgshop/testsystem" # Replace with your actual repo ID HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") STORE_ADDRESS = "Рынок Кербент, 6 ряд , контейнер 59 / 5 ряд , контейнер 68" # Your store address CURRENCY_CODE = 'KGS' CURRENCY_NAME = 'Кыргызский сом' DOWNLOAD_RETRIES = 3 DOWNLOAD_DELAY = 5 # In-memory storage for active sale sessions (temporary, not persistent) open_sale_sessions = {} logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): """Downloads specified files or all sync files from Hugging Face.""" token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE files_to_download = [specific_file] if specific_file else SYNC_FILES all_successful = True if not token_to_use: logging.warning("No Hugging Face token found (HF_TOKEN or HF_TOKEN_READ). Skipping download.") return False for file_name in files_to_download: success = False for attempt in range(retries + 1): try: hf_hub_download( repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use, local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False ) success = True logging.info(f"Successfully downloaded {file_name} from Hugging Face.") break except RepositoryNotFoundError: logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.") return False except HfHubHTTPError as e: if e.response.status_code == 404: logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.") # If the file doesn't exist remotely and doesn't exist locally, create an empty one if attempt == 0 and not os.path.exists(file_name): logging.info(f"Local file {file_name} not found and not in repo. Creating empty local file.") try: if file_name == DATA_FILE: with open(file_name, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': [], 'orders': {}}, f) except Exception as create_e: logging.error(f"Failed to create empty local file {file_name}: {create_e}") success = False # Treat 404 as success for this file, but continue loop for others break else: logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...") except requests.exceptions.RequestException as e: logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...") except Exception as e: logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True) if attempt < retries: time.sleep(delay) if not success and file_name in SYNC_FILES: # If a core sync file failed entirely logging.error(f"Failed to download {file_name} after {retries + 1} attempts.") all_successful = False # Continue attempting other files, but the overall process failed return all_successful def upload_db_to_hf(specific_file=None): """Uploads specified files or all sync files to Hugging Face.""" if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.") return try: api = HfApi() files_to_upload = [specific_file] if specific_file else SYNC_FILES for file_name in files_to_upload: if os.path.exists(file_name): try: api.upload_file( path_or_fileobj=file_name, path_in_repo=file_name, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) logging.info(f"Successfully uploaded {file_name} to Hugging Face.") except Exception as e: logging.error(f"Error uploading file {file_name} to Hugging Face: {e}") else: logging.warning(f"File {file_name} not found locally, skipping upload.") except Exception as e: logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True) def periodic_backup(): """Performs periodic backup to Hugging Face.""" backup_interval = 1800 # 30 minutes while True: time.sleep(backup_interval) logging.info("Starting periodic backup...") upload_db_to_hf() logging.info("Periodic backup finished.") def load_data(): """Loads data from the local JSON file, attempts download if missing or invalid.""" default_data = {'products': [], 'categories': [], 'orders': {}} data = default_data data_loaded_from_local = False if os.path.exists(DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) if isinstance(data, dict): data_loaded_from_local = True logging.info(f"Data loaded successfully from local file: {DATA_FILE}") else: logging.warning(f"Local {DATA_FILE} exists but is not a valid JSON object (dict). Attempting download.") data = default_data # Reset data to default if local file is invalid except json.JSONDecodeError: logging.warning(f"Local {DATA_FILE} exists but is not valid JSON. Attempting download.") data = default_data # Reset data to default if local file is invalid except Exception as e: logging.error(f"Error reading local {DATA_FILE}: {e}. Attempting download.") data = default_data # Reset data to default on other read errors if not data_loaded_from_local: logging.info(f"Local data not loaded, attempting download of {DATA_FILE} from Hugging Face.") if download_db_from_hf(specific_file=DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as file: downloaded_data = json.load(file) if isinstance(downloaded_data, dict): data = downloaded_data logging.info(f"Data loaded successfully after downloading {DATA_FILE}.") else: logging.error(f"Downloaded {DATA_FILE} is not a valid JSON object (dict). Using default data.") data = default_data except (FileNotFoundError, json.JSONDecodeError) as e: logging.error(f"Failed to read downloaded {DATA_FILE}: {e}. Using default data.") data = default_data except Exception as e: logging.error(f"Unexpected error reading downloaded {DATA_FILE}: {e}. Using default data.", exc_info=True) else: logging.warning(f"Failed to download {DATA_FILE}. Using default data.") data = default_data # If download failed and local file didn't exist or was invalid, try creating a default one if not os.path.exists(DATA_FILE): logging.info(f"Attempting to create an empty local {DATA_FILE}.") try: with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f) except Exception as create_e: logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}") # Ensure essential keys exist and add default values if needed if 'products' not in data: data['products'] = [] if 'categories' not in data: data['categories'] = [] if 'orders' not in data: data['orders'] = {} # Data structure migration/cleanup for products for product in data['products']: if 'id' not in product: product['id'] = uuid.uuid4().hex # Assign ID if missing logging.warning(f"Product missing ID, assigned {product['id']}") if 'stock' not in product: product['stock'] = 0 # Default stock to 0 if missing logging.warning(f"Product '{product.get('name', 'Unknown')}' missing stock, defaulted to 0") if 'barcode' not in product: product['barcode'] = '' # Add barcode field if missing if 'is_top' not in product: product['is_top'] = False # Add is_top field if missing if 'colors' not in product: product['colors'] = [] # Add colors field if missing # Ensure in_stock is correctly derived product['in_stock'] = product.get('stock', 0) > 0 return data def save_data(data): """Saves data to the local JSON file and uploads to Hugging Face.""" try: # Basic validation before saving if not isinstance(data, dict): logging.error("Attempted to save invalid data structure (not a dict). Aborting save.") return if 'products' not in data or 'categories' not in data or 'orders' not in data: logging.error("Attempted to save data structure missing essential keys. Aborting save.") return # Ensure in_stock is correctly derived before saving for product in data.get('products', []): product['in_stock'] = product.get('stock', 0) > 0 with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) logging.info(f"Data successfully saved to local file: {DATA_FILE}.") upload_db_to_hf(specific_file=DATA_FILE) except Exception as e: logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True) CATALOG_TEMPLATE = ''' Aikas_optom - Каталог
Aikas_optom Logo

Aikas_optom

Наш адрес: {{ store_address }}
{% for category in categories %} {% endfor %}
{% for product in products %}
{% if product.get('is_top', False) %} Топ {% endif %}
{% if product.get('photos') and product['photos']|length > 0 %} {{ product['name'] }} {% else %} No Image {% endif %}

{{ product['name'] }}

{{ "%.2f"|format(product['price']) }} {{ currency_code }}

{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}

{% endfor %} {% if not products %}

Товары пока не добавлены.

{% endif %}
''' PRODUCT_DETAIL_TEMPLATE = '''

{{ product['name'] }}

{% if product.get('photos') and product['photos']|length > 0 %} {% for photo in product['photos'] %}
{{ product['name'] }} - фото {{ loop.index }}
{% endfor %} {% else %}
Изображение отсутствует
{% endif %}
{% if product.get('photos') and product['photos']|length > 1 %}
{% endif %}

Категория: {{ product.get('category', 'Без категории') }}

Цена: {{ "%.2f"|format(product['price']) }} {{ currency_code }}

Остаток: {{ product.get('stock', 0) }} шт.

Описание:
{{ product.get('description', 'Описание отсутствует.')|replace('\\n', '
')|safe }}

{% set colors = product.get('colors', []) %} {% if colors and colors|select('ne', '')|list|length > 0 %}

Доступные цвета/варианты: {{ colors|select('ne', '')|join(', ') }}

{% endif %} {% if product.get('barcode') %}

Штрихкод: {{ product['barcode'] }}

{% endif %}
''' ORDER_TEMPLATE = ''' Заказ №{{ order.id }} - Aikas_optom
{% if order %}

Ваш Заказ №{{ order.id }}

Дата создания: {{ order.created_at }}

Товары в заказе

{% for item in order.cart %}
{{ item.name }}
{{ item.name }} {% if item.color != 'N/A' %}({{ item.color }}){% endif %} {{ "%.2f"|format(item.price) }} {{ currency_code }} × {{ item.quantity }}
{{ "%.2f"|format(item.price * item.quantity) }} {{ currency_code }}
{% endfor %}

Общая сумма товаров: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}

ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}

Информация о заказе

Заказ оформлен через сайт.

Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.

← Вернуться в каталог {% else %}

Ошибка

Заказ с таким ID не найден.

← Вернуться в каталог {% endif %}
''' ADMIN_TEMPLATE = ''' Админ-панель - Aikas_optom
Aikas_optom Logo

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

Перейти в каталог Касса
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %}

Синхронизация с Датацентром

Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.

Управление категориями

Добавить новую категорию

Существующие категории:

{% if categories %} {% for category in categories %}
{{ category }}
{% endfor %} {% else %}

Категорий пока нет.

{% endif %}

Отчеты и статистика

Общая статистика:

Всего заказов/продаж: {{ stats.total_orders }}

Общая сумма продаж: {{ "%.2f"|format(stats.total_revenue) }} {{ currency_code }}

Количество товаров: {{ stats.total_products }}

В наличии: {{ stats.products_in_stock }} ({{ "%.1f"|format(stats.products_in_stock_percent) }}%)

Нет в наличии: {{ stats.products_out_of_stock }}

Топ-5 продаваемых товаров (по количеству):

{% if stats.top_selling_products %}
    {% for product_name, quantity in stats.top_selling_products %}
  1. {{ product_name }}: {{ quantity }} шт.
  2. {% endfor %}
{% else %}

Нет данных о продажах.

{% endif %}

Товары с низким остатком (менее 10 шт.):

{% if stats.low_stock_products %}
    {% for product in stats.low_stock_products %}
  • {{ product.name }}: {{ product.stock }} шт.
  • {% endfor %}
{% else %}

Все товары в достаточном количестве.

{% endif %}

Управление товарами

Добавить новый товар


Список товаров:

{% if products %} {% for product in products %}
{% if product.get('photos') and product['photos']|length > 0 %} Фото {% else %} Нет фото {% endif %}

{{ product['name'] }} {% if product.get('in_stock', True) %} В наличии {% else %} Нет в наличии {% endif %} {% if product.get('is_top', False) %} Топ {% endif %}

Категория: {{ product.get('category', 'Без категории') }}

Цена: {{ "%.2f"|format(product['price']) }} {{ currency_code }}

Остаток: {{ product.get('stock', 0) }} шт.

Описание: {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}

{% if product.get('barcode') %}

Штрихкод: {{ product['barcode'] }}

{% endif %} {% if product.get('colors') and product['colors']|select('ne', '')|list|length > 0 %}

Цвета/Вар-ты: {{ product['colors']|select('ne', '')|join(', ') }}

{% endif %} {% if product.get('photos') and product['photos']|length > 1 %}

(Всего фото: {{ product['photos']|length }})

{% endif %}

Редактирование: {{ product['name'] }}

{% if product.get('photos') %}

Текущие фото:

{% for photo in product['photos'] %} Фото {{ loop.index }} {% endfor %}
{% endif %}
{% set current_colors = product.get('colors', []) %} {% if current_colors and current_colors|select('ne', '')|list|length > 0 %} {% for color in current_colors %} {% if color.strip() %}
{% endif %} {% endfor %} {% else %}
{% endif %}


{% endfor %} {% else %}

Товаров пока нет.

{% endif %}

Управление остатками


Управление заказами

''' SALE_TEMPLATE = ''' Касса - Aikas_optom
Aikas_optom Logo

Касса Aikas_optom

Каталог
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %}

Активные кассы

Текущая продажа (Нет активной кассы)

Поиск и выбор товара

{% for product in all_products %}
{{ product['name'] }} {{ product['name'] }} {{ "%.2f"|format(product['price']) }} {{ currency_code }} Остаток: {{ product.get('stock', 0) }} шт.
{% endfor %}

Добавить в чек

Товары в текущей продаже:

Список пуст.

Итоговая сумма: 0.00 {{ currency_code }}

''' @app.route('/') def catalog(): data = load_data() all_products = data.get('products', []) categories = sorted(data.get('categories', [])) # Filter and sort products for display in catalog products_in_stock = [p for p in all_products if p.get('stock', 0) > 0] # Sort by 'is_top' (True comes first) then by name products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())) return render_template_string( CATALOG_TEMPLATE, products=products_sorted, categories=categories, repo_id=REPO_ID, store_address=STORE_ADDRESS, currency_code=CURRENCY_CODE ) @app.route('/product/') def product_detail(product_id): data = load_data() all_products = data.get('products', []) # Only show details for products that exist and are in stock product = next((p for p in all_products if p.get('id') == product_id and p.get('stock', 0) > 0), None) if not product: # Render a simple error page or message return render_template_string( ''' Товар не найден

Товар не найден

Возможно, товар был удален, распродан или временно недоступен.

Вернуться в каталог
''' , url_for=url_for), 404 # Ensure photos list exists and has at least one placeholder if empty if not product.get('photos') or len(product['photos']) == 0: product['photos'] = ['placeholder.png'] # Use a dummy filename for template return render_template_string( PRODUCT_DETAIL_TEMPLATE, product=product, repo_id=REPO_ID, currency_code=CURRENCY_CODE ) @app.route('/create_order', methods=['POST']) def create_order(): """Creates an order from the shopping cart.""" order_data = request.get_json() if not order_data or 'cart' not in order_data or not order_data['cart']: return jsonify({"error": "Корзина пуста или не передана."}), 400 cart_items_raw = order_data['cart'] data = load_data() all_products_map = {p['id']: p for p in data.get('products', [])} total_price = 0 processed_cart = [] products_to_update_stock = {} # Dict to track stock changes for item_raw in cart_items_raw: required_keys = ['product_id', 'name', 'price', 'quantity', 'color'] # Include color if not all(k in item_raw for k in required_keys): logging.error(f"Invalid item format in cart: {item_raw}") return jsonify({"error": "Неверный формат товара в корзине. Пожалуйста, обновите страницу."}), 400 product_id = item_raw['product_id'] quantity = int(item_raw['quantity']) price = float(item_raw['price']) color = item_raw.get('color', 'N/A') # Get color, default to N/A if quantity <= 0: return jsonify({"error": f"Неверное количество ({quantity}) для товара '{item_raw['name']}'."}), 400 product_in_db = all_products_map.get(product_id) if not product_in_db: logging.error(f"Product ID {product_id} from cart not found in DB.") return jsonify({"error": f"Товар '{item_raw['name']}' не найден в базе данных. Возможно, он был удален. Пожалуйста, обновите страницу."}), 400 current_stock = product_in_db.get('stock', 0) if current_stock < quantity: logging.warning(f"Stock insufficient for product {product_id} ('{item_raw['name']}'). Requested {quantity}, Available {current_stock}.") # Restore stock in products_to_update_stock if it was decremented by a previous item in the same order (unlikely with unique IDs, but safe) products_to_update_stock.pop(product_id, None) return jsonify({"error": f"Недостаточно товара '{item_raw['name']}' на складе. Доступно: {current_stock} шт."}), 400 # Decrement stock for this product *in the pending updates* # This handles multiple items of the same product in the same order correctly products_to_update_stock[product_id] = products_to_update_stock.get(product_id, current_stock) - quantity total_price += price * quantity photo_filename = product_in_db.get('photos') selected_photo = photo_filename[0] if photo_filename and len(photo_filename) > 0 else 'placeholder.png' processed_cart.append({ "product_id": product_id, "name": item_raw['name'], "price": price, "quantity": quantity, "color": color, # Store color "photo": selected_photo, "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{selected_photo}" if selected_photo != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A" }) order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') new_order = { "id": order_id, "created_at": order_timestamp, "cart": processed_cart, "total_price": round(total_price, 2), "user_info": "Website Anonymous Order", # Indicate origin "status": "new", # Default status for website orders "customer_name": None, # Customer info not collected on catalog page "customer_contact": None # Customer info not collected on catalog page } try: # Apply the stock updates to the actual data object for prod_in_list in data['products']: if prod_in_list['id'] in products_to_update_stock: prod_in_list['stock'] = products_to_update_stock[prod_in_list['id']] prod_in_list['in_stock'] = prod_in_list['stock'] > 0 # Update in_stock status if 'orders' not in data or not isinstance(data.get('orders'), dict): data['orders'] = {} data['orders'][order_id] = new_order save_data(data) logging.info(f"Order {order_id} created and saved successfully.") return jsonify({"order_id": order_id}), 201 except Exception as e: logging.error(f"Failed to save order {order_id} or update stock: {e}", exc_info=True) # Attempt to revert stock changes in memory if save fails # Note: This doesn't revert changes if the process crashes *after* updating 'data' but *before* save_data finishes # A more robust system would use transactions or a proper database. try: current_data_after_failure = load_data() # Reload to potentially revert if save failed half-way # Find the original product stocks before the failed order processing original_products_map = {p['id']: p for p in load_data().get('products', [])} # This loads the state *before* the failed attempt for prod_in_list in data['products']: # Iterate through the data object *before* the failed save original_product = original_products_map.get(prod_in_list['id']) if original_product: prod_in_list['stock'] = original_product.get('stock', 0) prod_in_list['in_stock'] = prod_in_list['stock'] > 0 # Remove the failed order from the data object before returning error data['orders'].pop(order_id, None) # Optionally, try saving the reverted state, but be careful not to loop on save errors # save_data(data) # <-- Might cause infinite loop if save_data is the problem except Exception as revert_e: logging.error(f"Failed to attempt stock and order revert after primary save failure: {revert_e}", exc_info=True) return jsonify({"error": "Ошибка сервера при сохранении заказа. Пожалуйста, попробуйте позже."}), 500 @app.route('/order/') def view_order(order_id): """Displays a specific order details page.""" data = load_data() order = data.get('orders', {}).get(order_id) # Ensure photo_url is present for template (backward compatibility or if missing) if order and 'cart' in order: for item in order['cart']: if 'photo_url' not in item: photo_filename = item.get('photo', 'placeholder.png') item['photo_url'] = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{photo_filename}" if photo_filename != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A" return render_template_string(ORDER_TEMPLATE, order=order, repo_id=REPO_ID, currency_code=CURRENCY_CODE) @app.route('/admin', methods=['GET', 'POST']) def admin(): """Admin panel for managing products, categories, and orders.""" data = load_data() products = data.get('products', []) categories = data.get('categories', []) orders = data.get('orders', {}) if request.method == 'POST': action = request.form.get('action') redirect_url = url_for('admin') # Default redirect try: if action == 'add_category': category_name = request.form.get('category_name', '').strip() if category_name and category_name not in categories: categories.append(category_name) data['categories'] = categories save_data(data) flash(f"Категория '{category_name}' успешно добавлена.", 'success') elif not category_name: flash("Название категории не может быть пустым.", 'error') else: flash(f"Категория '{category_name}' уже существует.", 'error') elif action == 'delete_category': category_to_delete = request.form.get('category_name') if category_to_delete and category_to_delete in categories: categories.remove(category_to_delete) updated_count = 0 for product in products: if product.get('category') == category_to_delete: product['category'] = 'Без категории' updated_count += 1 data['categories'] = categories data['products'] = products # Update products reference in data save_data(data) flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success') else: flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error') elif action == 'add_product': name = request.form.get('name', '').strip() price_str = request.form.get('price', '').replace(',', '.') initial_stock_str = request.form.get('stock', '0') description = request.form.get('description', '').strip() category = request.form.get('category') barcode = request.form.get('barcode', '').strip() photos_files = request.files.getlist('photos') colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] is_top = 'is_top' in request.form if not name or not price_str: flash("Название и цена товара обязательны.", 'error') return redirect(redirect_url) try: price = round(float(price_str), 2) if price < 0: price = 0 except ValueError: flash("Неверный формат цены.", 'error') return redirect(redirect_url) try: initial_stock = int(initial_stock_str) if initial_stock < 0: initial_stock = 0 except ValueError: flash("Неверный формат начального остатка.", 'error') return redirect(redirect_url) # Check for duplicate barcode if provided if barcode: if any(p.get('barcode') == barcode for p in products): flash(f"Штрихкод '{barcode}' уже присвоен другому товару.", 'warning') # Use warning photos_list = [] if photos_files and any(f.filename for f in photos_files): if HF_TOKEN_WRITE: uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) api = HfApi() photo_limit = 10 uploaded_count = 0 for photo in photos_files: if uploaded_count >= photo_limit: flash(f"Загружено только первые {photo_limit} фото.", "warning") break if photo and photo.filename: try: ext = os.path.splitext(photo.filename)[1].lower() if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning") continue safe_name = secure_filename(name.replace(' ', '_'))[:50] photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}" temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) api.upload_file( path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Add photo for product {name}" ) photos_list.append(photo_filename) os.remove(temp_path) uploaded_count += 1 except Exception as e: flash(f"Ошибка при загрузке фото {photo.filename}.", 'error') logging.error(f"HF upload error for {photo.filename}: {e}", exc_info=True) if os.path.exists(temp_path): try: os.remove(temp_path) except OSError: pass elif photo and not photo.filename: # Handle empty file input case pass try: # Clean up temp directory if empty if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) except OSError as e: logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}") else: flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning") # Add placeholder if no photos uploaded/uploaded failed if not photos_list: photos_list = ['placeholder.png'] new_product = { 'id': uuid.uuid4().hex, 'name': name, 'price': price, 'description': description, 'category': category if category in categories else 'Без категории', 'photos': photos_list, 'colors': colors, 'stock': initial_stock, 'in_stock': initial_stock > 0, 'is_top': is_top, 'barcode': barcode # Save barcode } products.append(new_product) data['products'] = products # Update products reference save_data(data) flash(f"Товар '{name}' успешно добавлен.", 'success') elif action == 'edit_product': product_id = request.form.get('product_id') if not product_id: flash("Ошибка редактирования: ID товара не передан.", 'error') return redirect(redirect_url) product_to_edit = next((p for p in products if p['id'] == product_id), None) if not product_to_edit: flash(f"Ошибка редактирования: товар с ID '{product_id}' не найден.", 'error') return redirect(redirect_url) original_name = product_to_edit.get('name', 'N/A') original_barcode = product_to_edit.get('barcode', '') # Update fields if present in form product_to_edit['name'] = request.form.get('name', product_to_edit.get('name', '')).strip() price_str = request.form.get('price', str(product_to_edit.get('price', 0))).replace(',', '.') stock_str = request.form.get('stock', str(product_to_edit.get('stock', 0))) product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip() category = request.form.get('category') product_to_edit['category'] = category if category in categories else 'Без категории' product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()] product_to_edit['is_top'] = 'is_top' in request.form new_barcode = request.form.get('barcode', '').strip() product_to_edit['barcode'] = new_barcode # Update barcode try: stock = int(stock_str) if stock < 0: stock = 0 product_to_edit['stock'] = stock product_to_edit['in_stock'] = stock > 0 # Update in_stock status except ValueError: flash(f"Неверный формат остатка для товара '{original_name}'. Остаток не изменен.", 'warning') try: price = round(float(price_str), 2) if price < 0: price = 0 product_to_edit['price'] = price except ValueError: flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning') # Check for duplicate barcode *after* updating the product's barcode, but before saving if new_barcode and new_barcode != original_barcode: if any(p.get('barcode') == new_barcode and p['id'] != product_id for p in products): flash(f"Штрихкод '{new_barcode}' уже присвоен другому товару.", 'warning') # Use warning photos_files = request.files.getlist('photos') if photos_files and any(f.filename for f in photos_files): if HF_TOKEN_WRITE: uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) api = HfApi() new_photos_list = [] photo_limit = 10 uploaded_count = 0 old_photos = product_to_edit.get('photos', []) # Attempt to delete old photos first if old_photos and old_photos != ['placeholder.png']: try: logging.info(f"Attempting to delete old photos for {product_to_edit.get('name', 'Unknown')}: {old_photos}") api.delete_files( repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in old_photos if p and p != 'placeholder.png'], repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Delete old photos for product {product_to_edit['name']}" ) logging.info("Old photos deleted successfully.") except Exception as e: logging.error(f"Error deleting old photos {old_photos} from HF: {e}", exc_info=True) flash("Не удалось удалить старые фотографии с сервера. Новые фото будут загружены.", "warning") # Keep old photos in the list if deletion failed? Decide policy. # For simplicity, we'll replace the list regardless, relying on HF's deduplication # and hoping manual cleanup can happen later if needed. # Upload new photos for photo in photos_files: if uploaded_count >= photo_limit: flash(f"Загружено только первые {photo_limit} фото.", "warning") break if photo and photo.filename: try: ext = os.path.splitext(photo.filename)[1].lower() if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']: flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning") continue safe_name = secure_filename(product_to_edit['name'].replace(' ', '_'))[:50] # Ensure unique filename using timestamp and UUID part photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}_{uuid.uuid4().hex[:4]}{ext}" temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Update photo for product {product_to_edit['name']}") new_photos_list.append(photo_filename) os.remove(temp_path) uploaded_count += 1 except Exception as e: flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error') logging.error(f"HF upload error for {photo.filename}: {e}", exc_info=True) if os.path.exists(temp_path): try: os.remove(temp_path) except OSError: pass elif photo and not photo.filename: pass # Ignore empty file inputs try: if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) except OSError as e: logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}") product_to_edit['photos'] = new_photos_list if new_photos_list else ['placeholder.png'] # Replace with new list or placeholder if new_photos_list: flash("Фотографии товара успешно обновлены.", "success") else: flash("Новые фотографии не были выбраны или загружены. Старые фото удалены.", "warning") else: flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning") # Keep existing photos if token is not set, as we can't delete old ones anyway product_to_edit['photos'] = product_to_edit.get('photos', ['placeholder.png']) data['products'] = products # Update products reference (important after modification) save_data(data) flash(f"Товар '{product_to_edit.get('name', original_name)}' успешно обновлен.", 'success') elif action == 'delete_product': product_id = request.form.get('product_id') if not product_id: flash("Ошибка удаления: ID товара не передан.", 'error') return redirect(redirect_url) idx_to_delete = next((i for i, p in enumerate(products) if p['id'] == product_id), -1) if idx_to_delete == -1: flash(f"Ошибка удаления: товар с ID '{product_id}' не найден.", 'error') return redirect(redirect_url) deleted_product = products.pop(idx_to_delete) product_name = deleted_product.get('name', 'N/A') photos_to_delete = deleted_product.get('photos', []) if photos_to_delete and photos_to_delete != ['placeholder.png'] and HF_TOKEN_WRITE: try: logging.info(f"Attempting to delete photos for deleted product {product_name}: {photos_to_delete}") api = HfApi() api.delete_files( repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete if p and p != 'placeholder.png'], repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Delete photos for deleted product {product_name}" ) logging.info("Photos deleted successfully from HF.") except Exception as e: flash(f"Не удалось удалить фото для товара '{product_name}' с сервера. Товар удален локально.", "warning") logging.error(f"HF delete error for {product_name} photos: {e}", exc_info=True) elif photos_to_delete and photos_to_delete != ['placeholder.png'] and not HF_TOKEN_WRITE: flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning") data['products'] = products # Update products reference save_data(data) flash(f"Товар '{product_name}' удален.", 'success') elif action == 'adjust_stock': product_id = request.form.get('product_id') quantity_str = request.form.get('quantity') adjustment_type = request.form.get('adjustment_type') if not product_id or not quantity_str or not adjustment_type: flash("Не все поля заполнены для корректировки остатка.", 'error') return redirect(redirect_url) try: quantity_change = int(quantity_str) if quantity_change < 0: raise ValueError("Количество не может быть отрицательным.") if quantity_change == 0: flash("Количество для корректировки должно быть больше 0.", 'warning') return redirect(redirect_url) except ValueError: flash("Неверное количество для корректировки остатка.", 'error') return redirect(redirect_url) product = next((p for p in products if p['id'] == product_id), None) if not product: flash(f"Товар с ID '{product_id}' не найден.", 'error') return redirect(redirect_url) old_stock = product.get('stock', 0) if adjustment_type == 'add': product['stock'] = old_stock + quantity_change flash(f"Остаток для '{product['name']}' увеличен на {quantity_change}. Новый остаток: {product['stock']}.", 'success') elif adjustment_type == 'subtract': if old_stock < quantity_change: flash(f"Нельзя вычесть {quantity_change} со склада '{product['name']}'. Доступно: {old_stock}.", 'error') return redirect(redirect_url) product['stock'] = old_stock - quantity_change flash(f"Остаток для '{product['name']}' уменьшен на {quantity_change}. Новый остаток: {product['stock']}.", 'success') else: flash("Неверный тип корректировки остатка.", 'error') return redirect(redirect_url) product['in_stock'] = product['stock'] > 0 # Update in_stock status save_data(data) return redirect(redirect_url) else: flash(f"Неизвестное действие: {action}", 'warning') return redirect(redirect_url) except Exception as e: logging.error(f"Error processing admin action '{action}': {e}", exc_info=True) flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error') return redirect(redirect_url) # GET request logic current_data = load_data() # Sort products for display display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower()) # Sort categories for display display_categories = sorted(current_data.get('categories', [])) orders = current_data.get('orders', {}) # Sort orders by creation date, latest first display_orders = sorted(orders.values(), key=lambda o: o.get('created_at', ''), reverse=True) # Calculate statistics total_orders = len(orders) total_revenue = 0 product_sales_count = Counter() # Include sales from 'completed' and 'new' statuses for total revenue/sales count for order_id, order_data in orders.items(): # Use get() with default to avoid errors if keys are missing status = order_data.get('status') if status in ['completed', 'new']: # Consider both web orders ('new') and cashier sales ('completed') as revenue total_revenue += order_data.get('total_price', 0) for item in order_data.get('cart', []): # Use product name for stats, might be issues if names change product_sales_count[item.get('name', 'Unknown Product')] += item.get('quantity', 0) top_selling_products = product_sales_count.most_common(5) # Count products based on current stock products_in_stock_count = sum(1 for p in products if p.get('stock', 0) > 0) products_out_of_stock_count = sum(1 for p in products if p.get('stock', 0) <= 0) total_products_count = len(products) products_in_stock_percent = (products_in_stock_count / total_products_count * 100) if total_products_count > 0 else 0 low_stock_threshold = 10 # Low stock includes items in stock but below threshold low_stock_products = sorted([p for p in products if p.get('stock', 0) > 0 and p.get('stock', 0) < low_stock_threshold], key=lambda p: p['stock']) stats = { 'total_orders': total_orders, 'total_revenue': total_revenue, 'total_products': total_products_count, 'products_in_stock': products_in_stock_count, 'products_out_of_stock': products_out_of_stock_count, 'products_in_stock_percent': products_in_stock_percent, 'top_selling_products': top_selling_products, 'low_stock_products': low_stock_products } return render_template_string( ADMIN_TEMPLATE, products=display_products, categories=display_categories, display_orders=display_orders, stats=stats, repo_id=REPO_ID, currency_code=CURRENCY_CODE ) @app.route('/force_upload', methods=['POST']) def force_upload(): """Handler for manually triggering data upload to Hugging Face.""" try: upload_db_to_hf() flash("Данные успешно загружены на Hugging Face.", 'success') except Exception as e: logging.error(f"Error during forced upload: {e}", exc_info=True) flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error') return redirect(url_for('admin')) @app.route('/force_download', methods=['POST']) def force_download(): """Handler for manually triggering data download from Hugging Face.""" try: if download_db_from_hf(): # Reload data into memory after successful download load_data() flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success') else: flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error') except Exception as e: logging.error(f"Error during forced download: {e}", exc_info=True) flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error') return redirect(url_for('admin')) @app.route('/sale') def sale_register(): """Cashier register page.""" data = load_data() # Get all products, including out of stock, for display in the cashier interface all_products = sorted(data.get('products', []), key=lambda p: p.get('name', '').lower()) # Ensure each product has a photos list (even if empty, for template consistency) for product in all_products: if not product.get('photos'): product['photos'] = ['placeholder.png'] # Add placeholder if no photos return render_template_string( SALE_TEMPLATE, all_products=all_products, repo_id=REPO_ID, currency_code=CURRENCY_CODE ) @app.route('/sale/new_session', methods=['POST']) def new_sale_session(): """Creates a new sale session.""" session_id = uuid.uuid4().hex open_sale_sessions[session_id] = { 'cart_items': [], 'customer_name': '', 'customer_contact': '', 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') } logging.info(f"New sale session created: {session_id}") # No flash here, handled by JS notification on the sale page return jsonify({"session_id": session_id, "active_sessions": list(open_sale_sessions.keys())}) # Return session list @app.route('/sale/load_session/') def load_sale_session_api(session_id): """Loads details of a specific sale session.""" sale_session = open_sale_sessions.get(session_id) if not sale_session: logging.warning(f"Sale session {session_id} not found during load attempt.") return jsonify({"error": "Сессия не найдена."}), 404 # Reload product data from DB to get current stock levels for validation all_products_from_db = load_data()['products'] products_map = {p['id']: p for p in all_products_from_db} current_sale_cart_formatted = [] total_price = 0 # Add stock info and photo_url to cart items for display/validation for item in sale_session['cart_items']: product_data = products_map.get(item['product_id']) # Skip items if product no longer exists in DB if not product_data: logging.warning(f"Product ID {item['product_id']} in session {session_id} cart not found in DB.") continue photo_filename = item.get('photo', 'placeholder.png') item_total = item['price'] * item['quantity'] total_price += item_total current_sale_cart_formatted.append({ "temp_id": item['temp_id'], "product_id": item['product_id'], "name": item['name'], "price": item['price'], "quantity": item['quantity'], "color": item['color'], "photo": photo_filename, "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{photo_filename}" if photo_filename != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A", "max_stock": product_data.get('stock', 0) # Include current stock from DB }) # Prepare active sessions data structure including cart items counts for list display active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), # Include created_at for sorting 'cart_items': s_data['cart_items'] # Include items list for local processing } return jsonify({ "session_id": session_id, "customer_name": sale_session['customer_name'], "customer_contact": sale_session['customer_contact'], "current_sale_cart": current_sale_cart_formatted, "total_price": round(total_price, 2), "active_sessions_data": active_sessions_data # Send full session data }) @app.route('/sale/active_sessions') def get_active_sale_sessions(): """Returns a list of active sale sessions with summary info.""" active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list for local processing } return jsonify({"active_sessions_data": active_sessions_data}) @app.route('/sale/add_item', methods=['POST']) def add_item_to_sale_session(): """Adds an item to the current sale session cart.""" data_req = request.get_json() session_id = data_req.get('session_id') product_id = data_req.get('product_id') quantity = int(data_req.get('quantity', 0)) color = data_req.get('color', 'N/A') if not session_id or session_id not in open_sale_sessions: logging.warning(f"Add item failed: Session {session_id} not found.") return jsonify({"error": "Активная сессия не найдена."}), 404 if quantity <= 0: return jsonify({"error": "Количество товара должно быть больше 0."}), 400 all_data = load_data() product_in_db = next((p for p in all_data['products'] if p['id'] == product_id), None) if not product_in_db: logging.warning(f"Add item failed: Product {product_id} not found in DB.") return jsonify({"error": "Товар не найден в базе данных."}), 404 sale_session = open_sale_sessions[session_id] # Calculate how much of this product (and color) is already in the current session current_qty_in_this_session = 0 existing_item_in_session_index = -1 for i, item in enumerate(sale_session['cart_items']): if item['product_id'] == product_id and item['color'] == color: current_qty_in_this_session = item['quantity'] existing_item_in_session_index = i break proposed_new_qty_in_session = current_qty_in_this_session + quantity # Calculate total quantity of this product (and color) reserved across *all* open sessions total_reserved_across_sessions = 0 for s_id, s_data in open_sale_sessions.items(): for item_in_session in s_data['cart_items']: if item_in_session['product_id'] == product_id and item_in_session['color'] == color: # If it's the item we are trying to update in the current session, use the proposed new qty if s_id == session_id and item_in_session.get('temp_id') == sale_session['cart_items'][existing_item_in_session_index].get('temp_id') if existing_item_in_session_index != -1 else False: total_reserved_across_sessions += proposed_new_qty_in_session # Otherwise, use the quantity already in that session's item elif not (s_id == session_id and existing_item_in_session_index != -1): # Don't double count the item being updated in the current session total_reserved_across_sessions += item_in_session['quantity'] elif s_id != session_id: total_reserved_across_sessions += item_in_session['quantity'] # Refined stock check: Stock in DB must be >= total quantity across all open sessions *plus* the new quantity being added to *this* session. # Simpler check: The total quantity of THIS product/color in ALL open sessions *after* adding the new quantity must not exceed the stock. # The current total in the current session is `current_qty_in_this_session`. We are adding `quantity`. The new total in this session will be `proposed_new_qty_in_session`. # The total reserved in other sessions is `total_reserved_across_sessions` (calculated *without* the quantity in the current session's item). # So, the *total* across ALL sessions *if this addition succeeds* is `total_reserved_across_sessions + proposed_new_qty_in_session`. # Let's recalculate `total_reserved_across_sessions` to mean quantity in *other* sessions + quantity of *other items* in this session. total_reserved_excluding_this_specific_item = 0 for s_id, s_data in open_sale_sessions.items(): for item_in_session in s_data['cart_items']: if item_in_session['product_id'] == product_id and item_in_session['color'] == color: # Exclude the quantity of the item we are potentially merging into in the current session if s_id == session_id and existing_item_in_session_index != -1 and item_in_session.get('temp_id') == sale_session['cart_items'][existing_item_in_session_index].get('temp_id'): continue total_reserved_excluding_this_specific_item += item_in_session['quantity'] effective_stock_available_for_this_addition = product_in_db['stock'] - total_reserved_excluding_this_specific_item; if effective_stock_available_for_this_addition < quantity: logging.warning(f"Stock insufficient for product {product_id} ('{product_in_db['name']}'/{color}) across sessions. Stock {product_in_db['stock']}, Reserved elsewhere {total_reserved_excluding_this_specific_item}, Attempted add {quantity}.") available_to_add = effective_stock_available_for_this_addition # Can be negative if over-reserved return jsonify({ "error": f"Недостаточно товара '{product_in_db['name']}' (цвет: {color}) на складе. Доступно: {product_in_db['stock']} шт. (Из них {total_reserved_excluding_this_specific_item} шт. зарезервированы в других кассах/позициях этого чека). Вы можете добавить не более {max(0, available_to_add)} шт.", "max_quantity": max(0, available_to_add) # Return how many *can* be added }), 400 photo_filename = product_in_db.get('photos') selected_photo = photo_filename[0] if photo_filename and len(photo_filename) > 0 else 'placeholder.png' if existing_item_in_session_index != -1: sale_session['cart_items'][existing_item_in_session_index]['quantity'] = proposed_new_qty_in_session logging.info(f"Updated item {existing_item_in_session_index} quantity in session {session_id} to {proposed_new_qty_in_session}") else: new_item_temp_id = uuid.uuid4().hex sale_session['cart_items'].append({ "temp_id": new_item_temp_id, # Temporary ID for session management "product_id": product_id, "name": product_in_db['name'], "price": product_in_db['price'], "quantity": quantity, "color": color, "photo": selected_photo # Store just filename }) logging.info(f"Added new item {new_item_temp_id} to session {session_id}") # Recalculate totals and format cart for response current_sale_cart_formatted = [] total_price = 0 # Reload product data from DB again to ensure stock info in response is fresh (paranoia) all_products_from_db_fresh = load_data()['products'] products_map_fresh = {p['id']: p for p in all_products_from_db_fresh} for item in sale_session['cart_items']: product_data_fresh = products_map_fresh.get(item['product_id']) if not product_data_fresh: continue # Should not happen if checks above passed item_total = item['price'] * item['quantity'] total_price += item_total current_sale_cart_formatted.append({ "temp_id": item['temp_id'], "product_id": item['product_id'], "name": item['name'], "price": item['price'], "quantity": item['quantity'], "color": item['color'], "photo": item['photo'], # Use filename stored in session "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item['photo'] != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=No+Image", "max_stock": product_data_fresh.get('stock', 0) # Include current stock from DB }) active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({ "current_sale_cart": current_sale_cart_formatted, "total_price": round(total_price, 2), "active_sessions_data": active_sessions_data # Send full session data }) @app.route('/sale/remove_item', methods=['POST']) def remove_item_from_sale_session(): """Removes an item from the current sale session cart using its temporary ID.""" data_req = request.get_json() session_id = data_req.get('session_id') temp_id = data_req.get('temp_id') if not session_id or session_id not in open_sale_sessions: logging.warning(f"Remove item failed: Session {session_id} not found.") return jsonify({"error": "Активная сессия не найдена."}), 404 sale_session = open_sale_sessions[session_id] original_item_count = len(sale_session['cart_items']) sale_session['cart_items'] = [item for item in sale_session['cart_items'] if item['temp_id'] != temp_id] if len(sale_session['cart_items']) == original_item_count: logging.warning(f"Remove item failed: Item {temp_id} not found in session {session_id}.") return jsonify({"error": "Товар не найден в текущем чеке."}), 404 logging.info(f"Removed item {temp_id} from session {session_id}") # Recalculate totals and format cart for response current_sale_cart_formatted = [] total_price = 0 all_products_from_db = load_data()['products'] products_map = {p['id']: p for p in all_products_from_db} for item in sale_session['cart_items']: product_data = products_map.get(item['product_id']) if not product_data: continue # Should not happen item_total = item['price'] * item['quantity'] total_price += item_total current_sale_cart_formatted.append({ "temp_id": item['temp_id'], "product_id": item['product_id'], "name": item['name'], "price": item['price'], "quantity": item['quantity'], "color": item['color'], "photo": item['photo'], # Use filename stored in session "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item['photo'] != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A", "max_stock": product_data.get('stock', 0) # Include current stock from DB }) active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({ "current_sale_cart": current_sale_cart_formatted, "total_price": round(total_price, 2), "active_sessions_data": active_sessions_data # Send full session data }) @app.route('/sale/update_item_quantity', methods=['POST']) def update_item_quantity_in_sale(): """Updates the quantity of an item in the current sale session cart.""" data_req = request.get_json() session_id = data_req.get('session_id') temp_id = data_req.get('temp_id') new_quantity = int(data_req.get('new_quantity', 0)) if not session_id or session_id not in open_sale_sessions: logging.warning(f"Update quantity failed: Session {session_id} not found.") return jsonify({"error": "Активная сессия не найдена."}), 404 if new_quantity <= 0: return jsonify({"error": "Количество товара должно быть больше 0."}), 400 sale_session = open_sale_sessions[session_id] item_to_update = None item_index = -1 for i, item in enumerate(sale_session['cart_items']): if item['temp_id'] == temp_id: item_to_update = item item_index = i break if not item_to_update: logging.warning(f"Update quantity failed: Item {temp_id} not found in session {session_id}.") return jsonify({"error": "Товар не найден в текущем чеке."}), 404 all_data = load_data() product_in_db = next((p for p in all_data['products'] if p['id'] == item_to_update['product_id']), None) if not product_in_db: logging.error(f"Update quantity failed: Product {item_to_update['product_id']} from session cart not found in DB.") return jsonify({"error": "Товар не найден в базе данных."}), 400 current_product_id = item_to_update['product_id'] current_color = item_to_update['color'] # Calculate total quantity of this product (and color) reserved across *all* open sessions, # EXCLUDING the *old* quantity of the specific item we are updating. total_reserved_excluding_this_specific_item_old_qty = 0 for s_id, s_data in open_sale_sessions.items(): for item_in_session in s_data['cart_items']: if item_in_session['product_id'] == current_product_id and item_in_session['color'] == current_color: if s_id == session_id and item_in_session['temp_id'] == temp_id: continue # Exclude the item being updated total_reserved_excluding_this_specific_item_old_qty += item_in_session['quantity'] # The new total reserved across all sessions will be `total_reserved_excluding_this_specific_item_old_qty + new_quantity` new_total_reserved_across_sessions = total_reserved_excluding_this_specific_item_old_qty + new_quantity if product_in_db['stock'] < new_total_reserved_across_sessions: logging.warning(f"Update quantity failed: Stock insufficient for product {current_product_id} ('{product_in_db['name']}'/{current_color}) across sessions. Stock {product_in_db['stock']}, Reserved elsewhere/other items {total_reserved_excluding_this_specific_item_old_qty}, Attempted new total {new_total_reserved_across_sessions}.") available_qty = product_in_db['stock'] - total_reserved_excluding_this_specific_item_old_qty return jsonify({ "error": f"Недостаточно товара '{product_in_db['name']}' (цвет: {current_color}) на складе. Доступно: {product_in_db['stock']} шт. (Из них {total_reserved_excluding_this_specific_item_old_qty} шт. зарезервированы в других кассах/позициях этого чека). Вы можете установить количество не более {max(0, available_qty)} шт.", "max_quantity": max(0, available_qty) # Return how many *can* be set }), 400 # If validation passes, update the quantity sale_session['cart_items'][item_index]['quantity'] = new_quantity logging.info(f"Updated item {temp_id} quantity in session {session_id} to {new_quantity}") # Recalculate totals and format cart for response current_sale_cart_formatted = [] total_price = 0 # Reload product data from DB again to ensure stock info in response is fresh (paranoia) all_products_from_db_fresh = load_data()['products'] products_map_fresh = {p['id']: p for p in all_products_from_db_fresh} for item in sale_session['cart_items']: product_data_fresh = products_map_fresh.get(item['product_id']) if not product_data_fresh: continue # Should not happen item_total = item['price'] * item['quantity'] total_price += item_total current_sale_cart_formatted.append({ "temp_id": item['temp_id'], "product_id": item['product_id'], "name": item['name'], "price": item['price'], "quantity": item['quantity'], "color": item['color'], "photo": item['photo'], # Use filename stored in session "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item['photo'] != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A", "max_stock": product_data_fresh.get('stock', 0) # Include current stock from DB }) active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({ "current_sale_cart": current_sale_cart_formatted, "total_price": round(total_price, 2), "active_sessions_data": active_sessions_data # Send full session data }) @app.route('/sale/clear_session', methods=['POST']) def clear_sale_session(): """Clears all items from a specific sale session cart.""" data_req = request.get_json() session_id = data_req.get('session_id') if not session_id or session_id not in open_sale_sessions: logging.warning(f"Clear session failed: Session {session_id} not found.") return jsonify({"error": "Активная сессия не найдена."}), 404 open_sale_sessions[session_id]['cart_items'] = [] open_sale_sessions[session_id]['customer_name'] = '' # Clear customer info too open_sale_sessions[session_id]['customer_contact'] = '' logging.info(f"Session {session_id} cart cleared.") active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({ "current_sale_cart": [], # Return empty cart for the cleared session "total_price": 0.0, "active_sessions_data": active_sessions_data # Send updated session list }) @app.route('/sale/delete_session', methods=['POST']) def delete_sale_session_api(): """Deletes a specific sale session.""" data_req = request.get_json() session_id = data_req.get('session_id') if not session_id or session_id not in open_sale_sessions: logging.warning(f"Delete session failed: Session {session_id} not found.") return jsonify({"error": "Сессия не найдена."}), 404 del open_sale_sessions[session_id] logging.info(f"Session {session_id} deleted.") active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({"active_sessions_data": active_sessions_data}) # Send updated session list @app.route('/sale/finalize_sale', methods=['POST']) def finalize_sale(): """Registers a sale as a completed order.""" data_req = request.get_json() session_id = data_req.get('session_id') customer_name = data_req.get('customer_name', '').strip() customer_contact = data_req.get('customer_contact', '').strip() if not session_id or session_id not in open_sale_sessions: logging.warning(f"Finalize sale failed: Session {session_id} not found.") return jsonify({"error": "Активная сессия не найдена."}), 404 sale_session = open_sale_sessions[session_id] if not sale_session['cart_items']: return jsonify({"error": "Чек пуст. Невозможно зарегистрировать продажу."}), 400 db_data = load_data() products_map = {p['id']: p for p in db_data.get('products', [])} total_price = 0 processed_sale_cart = [] products_to_update_stock = {} # Dict to track stock changes {product_id: new_stock_level} # --- Stock Validation and Calculation --- # Recalculate the *total* quantity of each product/color combination across *all* sessions # This ensures we don't over-sell if the same item is in multiple open carts product_color_reserved_qty = Counter() for s_id, s_data in open_sale_sessions.items(): for item_in_session in s_data['cart_items']: # Use a tuple (product_id, color) as the key for the counter key = (item_in_session['product_id'], item_in_session['color']) product_color_reserved_qty[key] += item_in_session['quantity'] # Now, for the *current* sale session, validate against total reserved quantity for item in sale_session['cart_items']: product_id = item['product_id'] quantity = int(item['quantity']) # Quantity in THIS item in THIS session color = item['color'] product_in_db = products_map.get(product_id) if not product_in_db: logging.error(f"Finalize sale failed: Product {product_id} from session cart not found in DB.") return jsonify({"error": f"Товар '{item['name']}' не найден в базе данных."}), 400 db_stock = product_in_db.get('stock', 0) reserved_total = product_color_reserved_qty[(product_id, color)] # Total reserved quantity for this item/color across ALL sessions # Check if the total reserved quantity for this item/color exceeds the stock in the DB if db_stock < reserved_total: logging.error(f"Finalize sale failed: Stock insufficient for product {product_id} ('{item['name']}'/{color}). DB Stock: {db_stock}, Total Reserved across sessions: {reserved_total}. Cannot finalize.") # Find how much of this item is in *this specific* session qty_in_current_session = next((i['quantity'] for i in sale_session['cart_items'] if i['product_id'] == product_id and i['color'] == color), 0) return jsonify({ "error": f"Ошибка: Недостаточно товара '{item['name']}' (цвет: {color}) на складе. Доступно всего: {db_stock} шт. В текущей кассе: {qty_in_current_session} шт. Всего зарезервировано во всех кассах (включая эту): {reserved_total} шт. Пожалуйста, скорректируйте количество." }), 400 # Use 400 Bad Request for validation errors # Calculate the stock update based on the quantity sold in *this* specific session # The stock in the DB should be reduced by the quantity of this item *only* in the current session. # We build `products_to_update_stock` with the *final* stock level. # We need the current stock level *before* this sale. Let's reload data for this crucial step. fresh_db_data_for_stock_calc = load_data() fresh_products_map_for_stock_calc = {p['id']: p for p in fresh_db_data_for_stock_calc.get('products', [])} product_in_db_fresh = fresh_products_map_for_stock_calc.get(product_id) # Get the latest stock value if not product_in_db_fresh: logging.error(f"Finalize sale failed during stock calculation: Product {product_id} disappeared from DB.") return jsonify({"error": f"Внутренняя ошибка при расчете остатков для товара '{item['name']}'. Пожалуйста, попробуйте снова."}), 500 current_stock_before_this_sale = product_in_db_fresh.get('stock', 0) stock_after_this_item_sale = current_stock_before_this_sale - quantity # Store the calculated final stock level for this product ID. # Note: If the same product ID appears with different colors, this dict will store the # *last* calculated stock for that ID. This is correct because stock is per product ID, # not per color variant in our current model. Each item sold, regardless of color, reduces the total stock. products_to_update_stock[product_id] = stock_after_this_item_sale total_price += item['price'] * quantity # Format the item for the order record photo_filename = item['photo'] # Use the photo filename stored in the session item processed_sale_cart.append({ "product_id": product_id, "name": item['name'], "price": item['price'], "quantity": quantity, "color": item['color'], "photo": photo_filename, "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{photo_filename}" if photo_filename != 'placeholder.png' else "https://via.placeholder.com/60x60.png?text=N/A" }) # --- Create Order Record --- sale_id = f"SALE-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" sale_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') new_sale_record = { "id": sale_id, "created_at": sale_timestamp, "cart": processed_sale_cart, "total_price": round(total_price, 2), "user_info": "Admin Registered Sale (Cashier)", "status": "completed", # Mark cashier sales as 'completed' "customer_name": customer_name if customer_name else "Анонимный покупатель", "customer_contact": customer_contact if customer_contact else "Не указан" } # --- Apply Stock Updates and Save Data --- try: # Apply the calculated final stock levels to the data object loaded at the start of the request # This ensures we save the correct final state to data.json for prod_in_list in db_data['products']: if prod_in_list['id'] in products_to_update_stock: prod_in_list['stock'] = products_to_update_stock[prod_in_list['id']] prod_in_list['in_stock'] = prod_in_list['stock'] > 0 if 'orders' not in db_data or not isinstance(db_data.get('orders'), dict): db_data['orders'] = {} db_data['orders'][sale_id] = new_sale_record save_data(db_data) # Save the updated data # --- Cleanup Session --- del open_sale_sessions[session_id] # Remove the completed session logging.info(f"Sale {sale_id} registered successfully and session {session_id} deleted.") # Prepare response with updated active sessions list active_sessions_data = {} for s_id, s_data in open_sale_sessions.items(): active_sessions_data[s_id] = { 'id': s_id, 'item_count': sum(item['quantity'] for item in s_data['cart_items']), 'total_price': sum(item['price'] * item['quantity'] for item in s_data['cart_items']), 'created_at': s_data.get('created_at', ''), 'cart_items': s_data['cart_items'] # Include items list } return jsonify({"order_id": sale_id, "active_sessions_data": active_sessions_data}), 201 except Exception as e: logging.error(f"Failed to finalize sale {sale_id} or update stock: {e}", exc_info=True) # Note: Reverting stock perfectly on failure here is complex in this simple file-based DB. # It's safer to rely on the admin panel's stock adjustment if inconsistencies occur. return jsonify({"error": "Ошибка сервера при регистрации продажи. Пожалуйста, попробуйте позже."}), 500 if __name__ == '__main__': # Perform initial download before starting the app logging.info("Attempting initial database download from Hugging Face...") download_db_from_hf() logging.info("Initial download attempt finished. Loading data...") # Load data after download attempt (will load default if download failed) load_data() logging.info("Data loaded. Starting Flask application.") # Start periodic backup thread if write token is available if HF_TOKEN_WRITE: logging.info("HF_TOKEN (write) is set. Starting periodic backup thread.") backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() else: logging.warning("HF_TOKEN (write) not set. Periodic backup is disabled.") # Run the Flask application port = int(os.environ.get('PORT', 7860)) # Default to 7860 for Hugging Face Spaces app.run(debug=False, host='0.0.0.0', port=port)