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
Наш адрес: {{ 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 %}
{% else %}
{% endif %}
{% 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 %}
{{ product_name }}: {{ quantity }} шт.
{% 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 %}
'''
@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)