|
|
|
|
|
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' |
|
|
DATA_FILE = 'data.json' |
|
|
|
|
|
SYNC_FILES = [DATA_FILE] |
|
|
|
|
|
REPO_ID = "Kgshop/testsystem" |
|
|
HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
|
|
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
|
|
|
STORE_ADDRESS = "Рынок Кербент, 6 ряд , контейнер 59 / 5 ряд , контейнер 68" |
|
|
|
|
|
CURRENCY_CODE = 'KGS' |
|
|
CURRENCY_NAME = 'Кыргызский сом' |
|
|
|
|
|
DOWNLOAD_RETRIES = 3 |
|
|
DOWNLOAD_DELAY = 5 |
|
|
|
|
|
|
|
|
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 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 |
|
|
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: |
|
|
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.") |
|
|
all_successful = False |
|
|
|
|
|
|
|
|
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 |
|
|
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 |
|
|
except json.JSONDecodeError: |
|
|
logging.warning(f"Local {DATA_FILE} exists but is not valid JSON. Attempting download.") |
|
|
data = default_data |
|
|
except Exception as e: |
|
|
logging.error(f"Error reading local {DATA_FILE}: {e}. Attempting download.") |
|
|
data = default_data |
|
|
|
|
|
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 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}") |
|
|
|
|
|
|
|
|
|
|
|
if 'products' not in data: data['products'] = [] |
|
|
if 'categories' not in data: data['categories'] = [] |
|
|
if 'orders' not in data: data['orders'] = {} |
|
|
|
|
|
|
|
|
for product in data['products']: |
|
|
if 'id' not in product: |
|
|
product['id'] = uuid.uuid4().hex |
|
|
logging.warning(f"Product missing ID, assigned {product['id']}") |
|
|
if 'stock' not in product: |
|
|
product['stock'] = 0 |
|
|
logging.warning(f"Product '{product.get('name', 'Unknown')}' missing stock, defaulted to 0") |
|
|
if 'barcode' not in product: |
|
|
product['barcode'] = '' |
|
|
if 'is_top' not in product: |
|
|
product['is_top'] = False |
|
|
if 'colors' not in product: |
|
|
product['colors'] = [] |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 = ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Aikas_optom - Каталог</title> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css"> |
|
|
<style> |
|
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
|
body { font-family: 'Poppins', sans-serif; background: #F5F5F5; color: #263238; line-height: 1.6; transition: background 0.3s, color 0.3s; } |
|
|
body.dark-mode { background: #1A237E; color: #BBDEFB; } |
|
|
.container { max-width: 1300px; margin: 0 auto; padding: 20px; } |
|
|
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #CFD8DC; } |
|
|
body.dark-mode .header { border-bottom-color: #42A5F5; } |
|
|
.header h1 { font-size: 1.8rem; font-weight: 600; color: #3F51B5; } |
|
|
.theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #607D8B; transition: color 0.3s ease; } |
|
|
.theme-toggle:hover { color: #303F9F; } |
|
|
body.dark-mode .theme-toggle { color: #BBDEFB; } |
|
|
body.dark-mode .theme-toggle:hover { color: #90CAF9; } |
|
|
.store-address { padding: 15px; text-align: center; background-color: #FFFFFF; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #607D8B; } |
|
|
body.dark-mode .store-address { background-color: #283593; color: #BBDEFB; } |
|
|
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } |
|
|
.search-container { margin: 20px 0; text-align: center; } |
|
|
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #CFD8DC; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; } |
|
|
body.dark-mode #search-input { background-color: #283593; border-color: #42A5F5; color: #BBDEFB; } |
|
|
#search-input:focus { border-color: #3F51B5; box-shadow: 0 0 0 3px rgba(63, 81, 181, 0.2); } |
|
|
body.dark-mode #search-input:focus { border-color: #5C6BC0; box-shadow: 0 0 0 3px rgba(92, 107, 192, 0.3); } |
|
|
.category-filter { padding: 8px 16px; border: 1px solid #CFD8DC; border-radius: 20px; background-color: #fff; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-size: 0.9rem; font-weight: 400; color: #303F9F; } |
|
|
body.dark-mode .category-filter { background-color: #283593; border-color: #42A5F5; color: #90CAF9; } |
|
|
.category-filter.active, .category-filter:hover { background-color: #3F51B5; color: white; border-color: #3F51B5; box-shadow: 0 2px 10px rgba(63, 81, 181, 0.3); } |
|
|
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #5C6BC0; border-color: #5C6BC0; color: #1A237E; box-shadow: 0 2px 10px rgba(92, 107, 192, 0.4); } |
|
|
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; } |
|
|
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } } |
|
|
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } } |
|
|
|
|
|
.product { background: #fff; border-radius: 15px; padding: 0; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; height: 100%; border: 1px solid #E0E0E0;} |
|
|
body.dark-mode .product { background: #283593; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #42A5F5; } |
|
|
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); } |
|
|
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); } |
|
|
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; } |
|
|
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; } |
|
|
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; } |
|
|
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #263238; } |
|
|
body.dark-mode .product h2 { color: #BBDEFB; } |
|
|
.product-price { font-size: 1.2rem; color: #303F9F; font-weight: 700; text-align: center; margin: 5px 0; } |
|
|
body.dark-mode .product-price { color: #90CAF9; } |
|
|
.product-description { font-size: 0.85rem; color: #607D8B; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
|
body.dark-mode .product-description { color: #BBDEFB; } |
|
|
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; } |
|
|
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #3F51B5; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; } |
|
|
.product-button:hover { background-color: #303F9F; box-shadow: 0 4px 15px rgba(48, 63, 159, 0.4); transform: translateY(-2px); } |
|
|
.product-button i { margin-right: 5px; } |
|
|
.add-to-cart { background-color: #3F51B5; } |
|
|
.add-to-cart:hover { background-color: #303F9F; box-shadow: 0 4px 15px rgba(48, 63, 159, 0.4); } |
|
|
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #3F51B5; color: white; border: none; border-radius: 50%; width: 55px; height: 55px; font-size: 1.5rem; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(63, 81, 181, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; } |
|
|
#cart-button .fa-shopping-cart { margin-right: 0; } |
|
|
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #303F9F; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; } |
|
|
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; } |
|
|
.modal-content { background: #ffffff; margin: 5% auto; padding: 25px; border-radius: 15px; width: 90%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: slideIn 0.3s ease-out; position: relative; } |
|
|
body.dark-mode .modal-content { background: #283593; color: #BBDEFB; } |
|
|
@keyframes slideIn { from { transform: translateY(-30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } |
|
|
.close { position: absolute; top: 15px; right: 15px; font-size: 1.8rem; color: #aaa; cursor: pointer; transition: color 0.3s; line-height: 1; } |
|
|
.close:hover { color: #333; } |
|
|
body.dark-mode .close { color: #BBDEFB; } |
|
|
body.dark-mode .close:hover { color: #ffffff; } |
|
|
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #3F51B5; display: flex; align-items: center; gap: 10px;} |
|
|
body.dark-mode .modal-content h2 { color: #90CAF9; } |
|
|
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #CFD8DC; } |
|
|
body.dark-mode .cart-item { border-bottom-color: #42A5F5; } |
|
|
.cart-item:last-child { border-bottom: none; } |
|
|
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; } |
|
|
.cart-item-details { grid-column: 2; } |
|
|
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; } |
|
|
.cart-item-price { font-size: 0.9rem; color: #607D8B; } |
|
|
body.dark-mode .cart-item-price { color: #BBDEFB; } |
|
|
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; font-size: 1rem;} |
|
|
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; } |
|
|
.cart-item-remove:hover { color: #c53030; } |
|
|
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #CFD8DC; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; } |
|
|
body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1A237E; border-color: #42A5F5; color: #BBDEFB; } |
|
|
.cart-summary { margin-top: 20px; text-align: right; border-top: 1px solid #CFD8DC; padding-top: 15px; } |
|
|
body.dark-mode .cart-summary { border-top-color: #42A5F5; } |
|
|
.cart-summary strong { font-size: 1.2rem; } |
|
|
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; } |
|
|
.cart-actions .product-button { width: auto; flex-grow: 1; } |
|
|
.clear-cart { background-color: #9E9E9E; } |
|
|
.clear-cart:hover { background-color: #757575; box-shadow: 0 4px 15px rgba(117, 117, 117, 0.4); } |
|
|
.formulate-order-button { background-color: #3F51B5; } |
|
|
.formulate-order-button:hover { background-color: #303F9F; box-shadow: 0 4px 15px rgba(48, 63, 159, 0.4); } |
|
|
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #3F51B5; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;} |
|
|
.notification.show { opacity: 1;} |
|
|
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #607D8B; } |
|
|
body.dark-mode .no-results-message { color: #BBDEFB; } |
|
|
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); } |
|
|
.product { position: relative; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"> |
|
|
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
|
|
<img src="https://cdn-avatars.huggingface.co/v1/production/uploads/67c280ccb9d3dfdee58ecfdd/p2TVtSc6UQr0De1jDYBLk.jpeg" alt="Aikas_optom Logo" style="height: 40px; width: auto; border-radius: 4px;"> |
|
|
<h1>Aikas_optom</h1> |
|
|
</div> |
|
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Переключить тему"> |
|
|
<i class="fas fa-moon"></i> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="store-address">Наш адрес: {{ store_address }}</div> |
|
|
|
|
|
<div class="filters-container"> |
|
|
<button class="category-filter active" data-category="all">Все категории</button> |
|
|
{% for category in categories %} |
|
|
<button class="category-filter" data-category="{{ category }}">{{ category }}</button> |
|
|
{% endfor %} |
|
|
</div> |
|
|
|
|
|
<div class="search-container"> |
|
|
<input type="text" id="search-input" placeholder="Поиск по названию или описанию..."> |
|
|
</div> |
|
|
|
|
|
<div class="products-grid" id="products-grid"> |
|
|
{% for product in products %} |
|
|
<div class="product" |
|
|
data-name="{{ product['name']|lower }}" |
|
|
data-description="{{ product.get('description', '')|lower }}" |
|
|
data-category="{{ product.get('category', 'Без категории') }}" |
|
|
data-product-id="{{ product['id'] }}"> |
|
|
{% if product.get('is_top', False) %} |
|
|
<span class="top-product-indicator"><i class="fas fa-star"></i> Топ</span> |
|
|
{% endif %} |
|
|
<div class="product-image"> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" |
|
|
alt="{{ product['name'] }}" |
|
|
loading="lazy"> |
|
|
{% else %} |
|
|
<img src="https://via.placeholder.com/250x250.png?text=No+Image" alt="No Image" loading="lazy"> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div class="product-info"> |
|
|
<h2>{{ product['name'] }}</h2> |
|
|
<div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</div> |
|
|
<p class="product-description">{{ product.get('description', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}</p> |
|
|
</div> |
|
|
<div class="product-actions"> |
|
|
<button class="product-button" onclick="openModal('{{ product['id'] }}')">Подробнее</button> |
|
|
<button class="product-button add-to-cart" onclick="openQuantityModal('{{ product['id'] }}')"> |
|
|
<i class="fas fa-cart-plus"></i> В корзину |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not products %} |
|
|
<p class="no-results-message">Товары пока не добавлены.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="productModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span> |
|
|
<div id="modalContent"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="quantityModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span> |
|
|
<h2>Укажите количество и цвет</h2> |
|
|
<label for="quantityInput">Количество:</label> |
|
|
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1"> |
|
|
<label for="colorSelect">Цвет/Вариант:</label> |
|
|
<select id="colorSelect" class="color-select"></select> |
|
|
<button class="product-button add-to-cart" onclick="confirmAddToCart()"><i class="fas fa-check"></i> Добавить в корзину</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="cartModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span> |
|
|
<h2><i class="fas fa-shopping-cart"></i> Ваша корзина</h2> |
|
|
<div id="cartContent"><p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p></div> |
|
|
<div class="cart-summary"> |
|
|
<strong>Итого: <span id="cartTotal">0.00</span> {{ currency_code }}</strong> |
|
|
</div> |
|
|
<div class="cart-actions"> |
|
|
<button class="product-button clear-cart" onclick="clearCart()"> |
|
|
<i class="fas fa-trash"></i> Очистить корзину |
|
|
</button> |
|
|
<button class="product-button formulate-order-button" onclick="formulateOrder()"> |
|
|
<i class="fas fa-file-alt"></i> Сформировать заказ |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину"> |
|
|
<i class="fas fa-shopping-cart"></i> |
|
|
<span id="cart-count">0</span> |
|
|
</button> |
|
|
|
|
|
<div id="notification-placeholder"></div> |
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script> |
|
|
<script> |
|
|
const products = {{ products|tojson }}; |
|
|
const repoId = '{{ repo_id }}'; |
|
|
const currencyCode = '{{ currency_code }}'; |
|
|
let selectedProductId = null; |
|
|
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]'); |
|
|
|
|
|
function toggleTheme() { |
|
|
document.body.classList.toggle('dark-mode'); |
|
|
const icon = document.querySelector('.theme-toggle i'); |
|
|
const isDarkMode = document.body.classList.contains('dark-mode'); |
|
|
icon.classList.toggle('fa-moon', !isDarkMode); |
|
|
icon.classList.toggle('fa-sun', isDarkMode); |
|
|
localStorage.setItem('soolaTheme', isDarkMode ? 'dark' : 'light'); |
|
|
} |
|
|
|
|
|
function applyInitialTheme() { |
|
|
const savedTheme = localStorage.getItem('soolaTheme'); |
|
|
if (savedTheme === 'dark') { |
|
|
document.body.classList.add('dark-mode'); |
|
|
const icon = document.querySelector('.theme-toggle i'); |
|
|
if (icon) icon.classList.replace('fa-moon', 'fa-sun'); |
|
|
} else if (!savedTheme) { |
|
|
// Check system preference |
|
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { |
|
|
document.body.classList.add('dark-mode'); |
|
|
const icon = document.querySelector('.theme-toggle i'); |
|
|
if (icon) icon.classList.replace('fa-moon', 'fa-sun'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function openModal(productId) { |
|
|
const modalContent = document.getElementById('modalContent'); |
|
|
modalContent.innerHTML = '<p style="text-align:center; padding: 40px;">Загрузка...</p>'; |
|
|
fetch('/product/' + productId) |
|
|
.then(response => { |
|
|
if (!response.ok) throw new Error(`Ошибка ${response.status}: ${response.statusText}`); |
|
|
return response.text(); |
|
|
}) |
|
|
.then(data => { |
|
|
modalContent.innerHTML = data; |
|
|
initializeSwiper(); |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Ошибка загрузки деталей продукта:', error); |
|
|
modalContent.innerHTML = `<p style="color: red; text-align:center; padding: 40px;">Не удалось загрузить информацию о товаре. ${error.message}</p>`; |
|
|
}); |
|
|
const modal = document.getElementById('productModal'); |
|
|
if (modal) { |
|
|
modal.style.display = "block"; |
|
|
document.body.style.overflow = 'hidden'; |
|
|
} |
|
|
} |
|
|
|
|
|
function closeModal(modalId) { |
|
|
const modal = document.getElementById(modalId); |
|
|
if (modal) { |
|
|
modal.style.display = "none"; |
|
|
} |
|
|
const anyModalOpen = document.querySelector('.modal[style*="display: block"]'); |
|
|
if (!anyModalOpen) { |
|
|
document.body.style.overflow = 'auto'; |
|
|
} |
|
|
} |
|
|
|
|
|
function initializeSwiper() { |
|
|
const swiperContainer = document.querySelector('#productModal .swiper-container'); |
|
|
if (swiperContainer) { |
|
|
new Swiper(swiperContainer, { |
|
|
slidesPerView: 1, |
|
|
spaceBetween: 20, |
|
|
loop: true, |
|
|
grabCursor: true, |
|
|
pagination: { el: '.swiper-pagination', clickable: true }, |
|
|
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' }, |
|
|
zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' }, |
|
|
autoplay: { delay: 5000, disableOnInteraction: true, }, |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
function openQuantityModal(productId) { |
|
|
selectedProductId = productId; |
|
|
const product = products.find(p => p.id === productId); |
|
|
if (!product) { |
|
|
alert("Ошибка: товар не найден."); |
|
|
return; |
|
|
} |
|
|
|
|
|
const quantityInput = document.getElementById('quantityInput'); |
|
|
quantityInput.value = 1; |
|
|
// Use the product's actual stock for max quantity |
|
|
quantityInput.max = product.stock; |
|
|
quantityInput.oninput = () => { |
|
|
let val = parseInt(quantityInput.value); |
|
|
if (isNaN(val) || val < 1) val = 1; |
|
|
// Check against currently available stock considering other items already in cart |
|
|
let currentQtyInCart = cart.reduce((sum, item) => { |
|
|
// Sum quantity for this product and selected color |
|
|
if (item.product_id === product.id && item.color === saleColorSelect.value) { |
|
|
return sum + item.quantity; |
|
|
} |
|
|
return sum; |
|
|
}, 0); |
|
|
|
|
|
if (product.stock < currentQtyInCart + val) { |
|
|
val = product.stock - currentQtyInCart; |
|
|
if (val < 0) val = 0; // Should not happen if cart check is correct, but safety |
|
|
quantityInput.value = val; |
|
|
alert(`Недостаточно товара "${product.name}" (цвет: ${saleColorSelect.value}). Доступно: ${product.stock} шт. (Уже в корзине: ${currentQtyInCart}). Вы можете добавить не более ${val} шт.`); |
|
|
} else { |
|
|
quantityInput.value = val; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const colorSelect = document.getElementById('colorSelect'); |
|
|
const colorLabel = document.querySelector('label[for="colorSelect"]'); |
|
|
colorSelect.innerHTML = ''; |
|
|
|
|
|
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : []; |
|
|
|
|
|
if (validColors.length > 0) { |
|
|
validColors.forEach(color => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = color.trim(); |
|
|
option.text = color.trim(); |
|
|
colorSelect.appendChild(option); |
|
|
}); |
|
|
colorSelect.style.display = 'block'; |
|
|
if(colorLabel) colorLabel.style.display = 'block'; |
|
|
} else { |
|
|
const option = document.createElement('option'); |
|
|
option.value = 'N/A'; |
|
|
option.text = 'N/A'; |
|
|
colorSelect.appendChild(option); |
|
|
colorSelect.style.display = 'none'; |
|
|
if(colorLabel) colorLabel.style.display = 'none'; |
|
|
} |
|
|
|
|
|
const modal = document.getElementById('quantityModal'); |
|
|
if(modal) { |
|
|
modal.style.display = 'block'; |
|
|
document.body.style.overflow = 'hidden'; |
|
|
} |
|
|
} |
|
|
|
|
|
function confirmAddToCart() { |
|
|
if (selectedProductId === null) return; |
|
|
|
|
|
const quantityInput = document.getElementById('quantityInput'); |
|
|
const quantity = parseInt(quantityInput.value); |
|
|
const colorSelect = document.getElementById('colorSelect'); |
|
|
const color = colorSelect.style.display !== 'none' && colorSelect.value ? colorSelect.value : 'N/A'; |
|
|
|
|
|
if (isNaN(quantity) || quantity <= 0) { |
|
|
alert("Пожалуйста, укажите корректное количество (больше 0)."); |
|
|
quantityInput.focus(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const product = products.find(p => p.id === selectedProductId); |
|
|
if (!product) { |
|
|
alert("Ошибка добавления: товар не найден."); |
|
|
return; |
|
|
} |
|
|
|
|
|
let currentTotalInCartForThisItem = cart.reduce((sum, item) => { |
|
|
if (item.product_id === product.id && item.color === color) { |
|
|
return sum + item.quantity; |
|
|
} |
|
|
return sum; |
|
|
}, 0); |
|
|
|
|
|
const requestedTotal = currentTotalInCartForThisItem + quantity; |
|
|
|
|
|
if (requestedTotal > product.stock) { |
|
|
alert(`Недостаточно товара "${product.name}" (цвет: ${color}). Доступно: ${product.stock} шт. (Уже в корзине: ${currentTotalInCartForThisItem}).`); |
|
|
quantityInput.value = product.stock - currentTotalInCartForThisItem; |
|
|
if (parseInt(quantityInput.value) < 0) quantityInput.value = 0; |
|
|
quantityInput.focus(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const cartItemId = `${product.id}-${color}`; |
|
|
const existingItemIndexInCart = cart.findIndex(item => item.id === cartItemId); // Find by compound ID |
|
|
if (existingItemIndexInCart > -1) { |
|
|
cart[existingItemIndexInCart].quantity = requestedTotal; // Update quantity |
|
|
} else { |
|
|
cart.push({ |
|
|
id: cartItemId, // Use compound ID for uniqueness |
|
|
product_id: product.id, |
|
|
name: product.name, |
|
|
price: product.price, |
|
|
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null, |
|
|
quantity: quantity, |
|
|
color: color |
|
|
}); |
|
|
} |
|
|
|
|
|
localStorage.setItem('soolaCart', JSON.stringify(cart)); |
|
|
closeModal('quantityModal'); |
|
|
updateCartButton(); |
|
|
showNotification(`${product.name} добавлен в корзину!`); |
|
|
} |
|
|
|
|
|
function updateCartButton() { |
|
|
const cartCountElement = document.getElementById('cart-count'); |
|
|
const cartButton = document.getElementById('cart-button'); |
|
|
if (!cartCountElement || !cartButton) return; |
|
|
|
|
|
let totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); // Sum up quantities |
|
|
|
|
|
if (totalItems > 0) { |
|
|
cartCountElement.textContent = totalItems; |
|
|
cartButton.style.display = 'flex'; |
|
|
} else { |
|
|
cartCountElement.textContent = '0'; |
|
|
cartButton.style.display = 'none'; |
|
|
} |
|
|
} |
|
|
|
|
|
function openCartModal() { |
|
|
const cartContent = document.getElementById('cartContent'); |
|
|
const cartTotalElement = document.getElementById('cartTotal'); |
|
|
if (!cartContent || !cartTotalElement) return; |
|
|
|
|
|
let total = 0; |
|
|
|
|
|
if (cart.length === 0) { |
|
|
cartContent.innerHTML = '<p style="text-align: center; padding: 20px;">Ваша корзина пуста.</p>'; |
|
|
cartTotalElement.textContent = '0.00'; |
|
|
} else { |
|
|
cartContent.innerHTML = cart.map(item => { |
|
|
const itemTotal = item.price * item.quantity; |
|
|
total += itemTotal; |
|
|
const photoUrl = item.photo |
|
|
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}` |
|
|
: 'https://via.placeholder.com/60x60.png?text=N/A'; |
|
|
const colorText = item.color && item.color !== 'N/A' ? ` (Цвет: ${item.color})` : ''; |
|
|
|
|
|
return ` |
|
|
<div class="cart-item"> |
|
|
<img src="${photoUrl}" alt="${item.name}"> |
|
|
<div class="cart-item-details"> |
|
|
<strong>${item.name}${colorText}</strong> |
|
|
<p class="cart-item-price">${item.price.toFixed(2)} ${currencyCode} × ${item.quantity}</p> |
|
|
</div> |
|
|
<span class="cart-item-total">${itemTotal.toFixed(2)} ${currencyCode}</span> |
|
|
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар">×</button> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
cartTotalElement.textContent = total.toFixed(2); |
|
|
} |
|
|
const modal = document.getElementById('cartModal'); |
|
|
if (modal) { |
|
|
modal.style.display = 'block'; |
|
|
document.body.style.overflow = 'hidden'; |
|
|
} |
|
|
} |
|
|
|
|
|
function removeFromCart(itemId) { |
|
|
cart = cart.filter(item => item.id !== itemId); // Filter by compound ID |
|
|
localStorage.setItem('soolaCart', JSON.stringify(cart)); |
|
|
openCartModal(); // Re-render cart modal |
|
|
updateCartButton(); |
|
|
} |
|
|
|
|
|
function clearCart() { |
|
|
if (confirm("Вы уверены, что хотите очистить корзину?")) { |
|
|
cart = []; |
|
|
localStorage.removeItem('soolaCart'); |
|
|
openCartModal(); |
|
|
updateCartButton(); |
|
|
} |
|
|
} |
|
|
|
|
|
function formulateOrder() { |
|
|
if (cart.length === 0) { |
|
|
alert("Корзина пуста! Добавьте товары перед формированием заказа."); |
|
|
return; |
|
|
} |
|
|
|
|
|
const orderData = { |
|
|
cart: cart |
|
|
}; |
|
|
|
|
|
const formulateButton = document.querySelector('.formulate-order-button'); |
|
|
if (formulateButton) formulateButton.disabled = true; |
|
|
|
|
|
showNotification("Формируем заказ...", 5000); |
|
|
|
|
|
fetch('/create_order', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(orderData) |
|
|
}) |
|
|
.then(response => { |
|
|
// Re-enable button regardless of success/failure |
|
|
if (formulateButton) formulateButton.disabled = false; |
|
|
|
|
|
if (!response.ok) { |
|
|
return response.json().then(err => { |
|
|
// If server returned a specific error related to stock |
|
|
if (err.error && err.error.includes('Недостаточно товара')) { |
|
|
// Potentially update local cart based on server response, or just alert |
|
|
// For now, just alert and leave cart as is for user to fix |
|
|
throw new Error(err.error); |
|
|
} |
|
|
throw new Error(err.error || `Не удалось создать заказ. Статус: ${response.status}`); |
|
|
}); |
|
|
} |
|
|
return response.json(); |
|
|
}) |
|
|
.then(data => { |
|
|
if (data.order_id) { |
|
|
localStorage.removeItem('soolaCart'); |
|
|
cart = []; |
|
|
updateCartButton(); |
|
|
closeModal('cartModal'); |
|
|
window.location.href = `/order/${data.order_id}`; |
|
|
} else { |
|
|
// This case should ideally be caught by the !response.ok check, but as a fallback |
|
|
throw new Error('Не получен ID заказа от сервера.'); |
|
|
} |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Ошибка при формировании заказа:', error); |
|
|
alert(`Ошибка: ${error.message}`); |
|
|
}); |
|
|
} |
|
|
|
|
|
function filterProducts() { |
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim(); |
|
|
const activeCategoryButton = document.querySelector('.category-filter.active'); |
|
|
const activeCategory = activeCategoryButton ? activeCategoryButton.dataset.category : 'all'; |
|
|
const grid = document.getElementById('products-grid'); |
|
|
let visibleProducts = 0; |
|
|
|
|
|
const existingNoResults = grid.querySelector('.no-results-message'); |
|
|
if (existingNoResults) existingNoResults.remove(); |
|
|
|
|
|
document.querySelectorAll('.products-grid .product').forEach(productElement => { |
|
|
const name = productElement.getAttribute('data-name'); |
|
|
const description = productElement.getAttribute('data-description'); |
|
|
const category = productElement.getAttribute('data-category'); |
|
|
|
|
|
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm); |
|
|
const matchesCategory = activeCategory === 'all' || category === activeCategory; |
|
|
|
|
|
if (matchesSearch && matchesCategory) { |
|
|
productElement.style.display = 'flex'; |
|
|
visibleProducts++; |
|
|
} else { |
|
|
productElement.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
if (visibleProducts === 0 && products.length > 0) { |
|
|
const p = document.createElement('p'); |
|
|
p.className = 'no-results-message'; |
|
|
p.textContent = 'По вашему запросу товары не найдены.'; |
|
|
grid.appendChild(p); |
|
|
} else if (products.length === 0 && !grid.querySelector('.no-results-message')) { |
|
|
const p = document.createElement('p'); |
|
|
p.className = 'no-results-message'; |
|
|
p.textContent = 'Товары пока не добавлены.'; |
|
|
grid.appendChild(p); |
|
|
} |
|
|
} |
|
|
|
|
|
function setupFilters() { |
|
|
const searchInput = document.getElementById('search-input'); |
|
|
const categoryFilters = document.querySelectorAll('.category-filter'); |
|
|
|
|
|
if(searchInput) searchInput.addEventListener('input', filterProducts); |
|
|
|
|
|
categoryFilters.forEach(filter => { |
|
|
filter.addEventListener('click', function() { |
|
|
categoryFilters.forEach(f => f.classList.remove('active')); |
|
|
this.classList.add('active'); |
|
|
filterProducts(); |
|
|
}); |
|
|
}); |
|
|
filterProducts(); |
|
|
} |
|
|
|
|
|
function showNotification(message, duration = 3000) { |
|
|
let placeholder = document.getElementById('notification-placeholder'); |
|
|
if (!placeholder) { |
|
|
const newPlaceholder = document.createElement('div'); |
|
|
newPlaceholder.id = 'notification-placeholder'; |
|
|
newPlaceholder.style.position = 'fixed'; |
|
|
newPlaceholder.style.bottom = '80px'; |
|
|
newPlaceholder.style.left = '50%'; |
|
|
newPlaceholder.style.transform = 'translateX(-50%)'; |
|
|
newPlaceholder.style.zIndex = '1002'; |
|
|
document.body.appendChild(newPlaceholder); |
|
|
placeholder = newPlaceholder; |
|
|
} |
|
|
|
|
|
// Remove any existing notifications before adding a new one |
|
|
placeholder.innerHTML = ''; |
|
|
|
|
|
const notification = document.createElement('div'); |
|
|
notification.className = 'notification'; |
|
|
notification.textContent = message; |
|
|
placeholder.appendChild(notification); |
|
|
|
|
|
// Trigger reflow to ensure transition runs |
|
|
void notification.offsetWidth; |
|
|
|
|
|
notification.classList.add('show'); |
|
|
|
|
|
setTimeout(() => { |
|
|
notification.classList.remove('show'); |
|
|
notification.addEventListener('transitionend', () => notification.remove()); |
|
|
}, duration); |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
applyInitialTheme(); |
|
|
updateCartButton(); |
|
|
setupFilters(); |
|
|
|
|
|
window.addEventListener('click', function(event) { |
|
|
// Check if click is outside modal content but inside modal background |
|
|
if (event.target.classList && event.target.classList.contains('modal')) { |
|
|
closeModal(event.target.id); |
|
|
} |
|
|
}); |
|
|
|
|
|
window.addEventListener('keydown', function(event) { |
|
|
if (event.key === 'Escape') { |
|
|
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => { |
|
|
closeModal(modal.id); |
|
|
}); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
PRODUCT_DETAIL_TEMPLATE = ''' |
|
|
<div style="padding: 10px;"> |
|
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #3F51B5;">{{ product['name'] }}</h2> |
|
|
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;"> |
|
|
<div class="swiper-wrapper"> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
{% for photo in product['photos'] %} |
|
|
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;"> |
|
|
<div class="swiper-zoom-container"> |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" |
|
|
alt="{{ product['name'] }} - фото {{ loop.index }}" |
|
|
style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;"> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center;"> |
|
|
<img src="https://via.placeholder.com/400x400.png?text=No+Image" alt="Изображение отсутствует" style="max-width: 100%; max-height: 400px; object-fit: contain;"> |
|
|
</div> |
|
|
{% endif %} |
|
|
</div> |
|
|
{% if product.get('photos') and product['photos']|length > 1 %} |
|
|
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div> |
|
|
<div class="swiper-button-next" style="color: #3F51B5;"></div> |
|
|
<div class="swiper-button-prev" style="color: #3F51B5;"></div> |
|
|
{% endif %} |
|
|
</div> |
|
|
|
|
|
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;"> |
|
|
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
|
|
<p style="font-size: 1.2rem; font-weight: bold; color: #303F9F;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p> |
|
|
<p><strong>Остаток:</strong> {{ product.get('stock', 0) }} шт.</p> |
|
|
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p> |
|
|
{% set colors = product.get('colors', []) %} |
|
|
{% if colors and colors|select('ne', '')|list|length > 0 %} |
|
|
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p> |
|
|
{% endif %} |
|
|
{% if product.get('barcode') %} |
|
|
<p><strong>Штрихкод:</strong> {{ product['barcode'] }}</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
''' |
|
|
|
|
|
ORDER_TEMPLATE = ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Заказ №{{ order.id }} - Aikas_optom</title> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<style> |
|
|
body { font-family: 'Poppins', sans-serif; background: #F5F5F5; color: #263238; line-height: 1.6; padding: 20px; } |
|
|
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); border: 1px solid #CFD8DC; } |
|
|
h1 { text-align: center; color: #3F51B5; margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; } |
|
|
h2 { color: #303F9F; margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #CFD8DC; padding-bottom: 8px;} |
|
|
.order-meta { font-size: 0.9rem; color: #607D8B; margin-bottom: 20px; text-align: center; } |
|
|
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #E0E0E0; } |
|
|
.order-item:last-child { border-bottom: none; } |
|
|
.order-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; border: 1px solid #E0E0E0;} |
|
|
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #263238;} |
|
|
.item-details span { font-size: 0.9rem; color: #607D8B; display: block;} |
|
|
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #303F9F;} |
|
|
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #3F51B5; text-align: right; } |
|
|
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; } |
|
|
.order-summary strong { font-size: 1.3rem; color: #3F51B5; } |
|
|
.customer-info { margin-top: 30px; background-color: #e8eaf6; padding: 20px; border-radius: 8px; border: 1px solid #CFD8DC;} |
|
|
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; } |
|
|
.customer-info strong { color: #303F9F; } |
|
|
.actions { margin-top: 30px; text-align: center; } |
|
|
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #3F51B5; color: white; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; } |
|
|
.button:hover { background-color: #303F9F; } |
|
|
.button:active { transform: scale(0.98); } |
|
|
.button i { font-size: 1.2rem; } |
|
|
.catalog-link { display: block; text-align: center; margin-top: 25px; color: #303F9F; text-decoration: none; font-size: 0.9rem; } |
|
|
.catalog-link:hover { text-decoration: underline; } |
|
|
.not-found { text-align: center; color: #c53030; font-size: 1.2rem; padding: 40px 0;} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
{% if order %} |
|
|
<h1><i class="fas fa-receipt"></i> Ваш Заказ №{{ order.id }}</h1> |
|
|
<p class="order-meta">Дата создания: {{ order.created_at }}</p> |
|
|
|
|
|
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2> |
|
|
<div id="orderItems"> |
|
|
{% for item in order.cart %} |
|
|
<div class="order-item"> |
|
|
<img src="{{ item.photo_url }}" alt="{{ item.name }}"> |
|
|
<div class="item-details"> |
|
|
<strong>{{ item.name }} {% if item.color != 'N/A' %}({{ item.color }}){% endif %}</strong> |
|
|
<span>{{ "%.2f"|format(item.price) }} {{ currency_code }} × {{ item.quantity }}</span> |
|
|
</div> |
|
|
<div class="item-total"> |
|
|
{{ "%.2f"|format(item.price * item.quantity) }} {{ currency_code }} |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
|
|
|
<div class="order-summary"> |
|
|
<p>Общая сумма товаров: <strong>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p> |
|
|
<p><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p> |
|
|
</div> |
|
|
|
|
|
<div class="customer-info"> |
|
|
<h2><i class="fas fa-info-circle"></i> Информация о заказе</h2> |
|
|
<p>Заказ оформлен через сайт.</p> |
|
|
<p>Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.</p> |
|
|
</div> |
|
|
|
|
|
<div class="actions"> |
|
|
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ в WhatsApp</button> |
|
|
</div> |
|
|
|
|
|
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a> |
|
|
|
|
|
<script> |
|
|
function sendOrderViaWhatsApp() { |
|
|
const orderId = '{{ order.id }}'; |
|
|
const orderUrl = `{{ request.url }}`; |
|
|
// Replace with your WhatsApp number |
|
|
const whatsappNumber = "996507003777"; |
|
|
|
|
|
let message = `Здравствуйте! Хочу подтвердить свой заказ на Aikas_optom:%0A%0A`; |
|
|
message += `*Номер заказа:* ${orderId}%0A`; |
|
|
message += `*Ссылка на заказ:* ${encodeURIComponent(orderUrl)}%0A%0A`; |
|
|
message += `Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.`; |
|
|
|
|
|
const whatsappUrl = `https://wa.me/${whatsappNumber}?text=${message}`; // Using wa.me for better compatibility |
|
|
window.open(whatsappUrl, '_blank'); |
|
|
} |
|
|
</script> |
|
|
|
|
|
{% else %} |
|
|
<h1 style="color: #c53030;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1> |
|
|
<p class="not-found">Заказ с таким ID не найден.</p> |
|
|
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a> |
|
|
{% endif %} |
|
|
</div> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
ADMIN_TEMPLATE = ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Админ-панель - Aikas_optom</title> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0"></script> |
|
|
<style> |
|
|
body { font-family: 'Poppins', sans-serif; background-color: #F5F5F5; color: #263238; padding: 20px; line-height: 1.6; } |
|
|
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
|
|
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #CFD8DC; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;} |
|
|
h1, h2, h3 { font-weight: 600; color: #3F51B5; margin-bottom: 15px; } |
|
|
h1 { font-size: 1.8rem; } |
|
|
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } |
|
|
h3 { font-size: 1.2rem; color: #303F9F; margin-top: 20px; } |
|
|
.section { margin-bottom: 30px; padding: 20px; background-color: #e8eaf6; border: 1px solid #CFD8DC; border-radius: 8px; } |
|
|
form { margin-bottom: 20px; } |
|
|
label { font-weight: 500; margin-top: 10px; display: block; color: #607D8B; font-size: 0.9rem;} |
|
|
input[type="text"], input[type="number"], input[type="password"], input[type="tel"], textarea, select, .admin-search-input { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #CFD8DC; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; } |
|
|
input:focus, textarea:focus, select:focus { border-color: #3F51B5; outline: none; box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.1); } |
|
|
textarea { min-height: 80px; resize: vertical; } |
|
|
input[type="file"] { padding: 8px; background-color: #e8eaf6; cursor: pointer; border: 1px solid #CFD8DC;} |
|
|
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #BBDEFB; border: 1px solid #90CAF9; cursor: pointer; margin-right: 10px;} |
|
|
input[type="checkbox"] { margin-right: 5px; vertical-align: middle; } |
|
|
label.inline-label { display: inline-block; margin-top: 10px; font-weight: normal; } |
|
|
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #3F51B5; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;} |
|
|
button:hover, .button:hover { background-color: #303F9F; } |
|
|
button:active, .button:active { transform: scale(0.98); } |
|
|
button[type="submit"] { min-width: 120px; justify-content: center; } |
|
|
.delete-button { background-color: #f56565; } |
|
|
.delete-button:hover { background-color: #e53e3e; } |
|
|
.add-button { background-color: #3F51B5; } |
|
|
.add-button:hover { background-color: #303F9F; } |
|
|
.item-list { display: grid; gap: 20px; } |
|
|
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #E0E0E0; } |
|
|
.item p { margin: 5px 0; font-size: 0.9rem; color: #607D8B; } |
|
|
.item strong { color: #263238; } |
|
|
.item .description { font-size: 0.85rem; color: #607D8B; max-height: 60px; overflow: hidden; text-overflow: ellipsis; } |
|
|
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } |
|
|
.item-actions button:not(.delete-button) { background-color: #3F51B5; } |
|
|
.item-actions button:not(.delete-button):hover { background-color: #303F9F; } |
|
|
.edit-form-container { margin-top: 15px; padding: 20px; background: #e8eaf6; border: 1px dashed #CFD8DC; border-radius: 6px; display: none; } |
|
|
details { background-color: #e8eaf6; border: 1px solid #CFD8DC; border-radius: 8px; margin-bottom: 20px; } |
|
|
details > summary { cursor: pointer; font-weight: 600; color: #303F9F; display: block; padding: 15px; border-bottom: 1px solid #CFD8DC; list-style: none; position: relative; } |
|
|
details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #3F51B5; } |
|
|
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } |
|
|
details[open] > summary { border-bottom: 1px solid #CFD8DC; } |
|
|
details .form-content { padding: 20px; } |
|
|
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } |
|
|
.color-input-group input { flex-grow: 1; margin: 0; } |
|
|
.remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; } |
|
|
.remove-color-btn:hover { background-color: #e53e3e; } |
|
|
.add-color-btn { background-color: #BBDEFB; color: #303F9F; } |
|
|
.add-color-btn:hover { background-color: #90CAF9; } |
|
|
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #CFD8DC; object-fit: cover;} |
|
|
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } |
|
|
.download-hf-button { background-color: #9E9E9E; } |
|
|
.download-hf-button:hover { background-color: #757575; } |
|
|
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; } |
|
|
.flex-item { flex: 1; min-width: 350px; } |
|
|
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;} |
|
|
.message.success { background-color: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7;} |
|
|
.message.error { background-color: #ffebee; color: #c62828; border: 1px solid #ef9a9a;} |
|
|
.message.warning { background-color: #fffde7; color: #ef6c00; border: 1px solid #ffcc80; } |
|
|
.status-indicator { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; margin-left: 10px; vertical-align: middle; } |
|
|
.status-indicator.in-stock { background-color: #c8e6c9; color: #388e3c; } |
|
|
.status-indicator.out-of-stock { background-color: #ffcdd2; color: #d32f2f; } |
|
|
.status-indicator.top-product { background-color: #ffe0b2; color: #e65100; margin-left: 5px;} |
|
|
|
|
|
#sale-items-list div { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 8px 0; |
|
|
border-bottom: 1px dashed #CFD8DC; |
|
|
} |
|
|
#sale-items-list div:last-child { border-bottom: none; } |
|
|
#sale-items-list p { text-align: center; color: #607D8B; padding: 10px; } |
|
|
|
|
|
.order-item h3 { |
|
|
margin-top: 0; |
|
|
margin-bottom: 10px; |
|
|
color: #3F51B5; |
|
|
} |
|
|
.order-item p { |
|
|
margin: 5px 0; |
|
|
font-size: 0.95rem; |
|
|
} |
|
|
.order-item details { |
|
|
margin-top: 10px; |
|
|
background-color: #fff; |
|
|
border: 1px solid #CFD8DC; |
|
|
} |
|
|
.order-item details summary { |
|
|
color: #303F9F; |
|
|
padding: 10px 15px; |
|
|
border-bottom: none; |
|
|
} |
|
|
.order-item details[open] summary { |
|
|
border-bottom: 1px solid #CFD8DC; |
|
|
} |
|
|
.order-item details ul { |
|
|
padding: 10px 20px; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
.admin-search-input { |
|
|
width: 100%; |
|
|
padding: 10px 12px; |
|
|
margin-bottom: 15px; |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 6px; |
|
|
font-size: 0.95rem; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
.header a.button { background-color: #3F51B5 !important; } |
|
|
.header a.button:hover { background-color: #303F9F !important; } |
|
|
|
|
|
/* Barcode Scanner Modal */ |
|
|
#scannerModal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
z-index: 1003; /* Higher than other modals */ |
|
|
left: 0; |
|
|
top: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background-color: rgba(0,0,0,0.8); |
|
|
backdrop-filter: blur(8px); |
|
|
overflow: auto; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
flex-direction: column; |
|
|
color: white; |
|
|
} |
|
|
#scannerModal .modal-content { |
|
|
background: none; /* No background for scanner */ |
|
|
box-shadow: none; |
|
|
margin: auto; /* Center content */ |
|
|
max-width: 90%; |
|
|
width: 500px; /* Max width for video feed */ |
|
|
padding: 0; |
|
|
position: relative; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
} |
|
|
#scannerModal .close { |
|
|
position: absolute; |
|
|
top: 15px; |
|
|
right: 15px; |
|
|
z-index: 1004; |
|
|
color: rgba(255,255,255,0.8); |
|
|
font-size: 2rem; |
|
|
} |
|
|
#scannerModal .close:hover { color: white; } |
|
|
|
|
|
#interactive.viewport { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
height: 300px; /* Adjust height as needed */ |
|
|
text-align: center; |
|
|
overflow: hidden; |
|
|
border-radius: 10px; |
|
|
background-color: #333; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
} |
|
|
#interactive video { |
|
|
max-width: 100%; |
|
|
max-height: 100%; |
|
|
object-fit: cover; /* Cover the container */ |
|
|
display: block; |
|
|
} |
|
|
#interactive .scan-line { |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 2px; |
|
|
background: red; |
|
|
animation: scan-line 2s linear infinite; |
|
|
} |
|
|
@keyframes scan-line { |
|
|
0% { top: 0%; } |
|
|
50% { top: 100%; } |
|
|
100% { top: 0%; } |
|
|
} |
|
|
#scannerModal p { margin-top: 15px; font-size: 1rem; text-align: center; } |
|
|
.barcode-input-group { display: flex; gap: 10px; align-items: flex-end; } |
|
|
.barcode-input-group input { flex-grow: 1; margin-top: 5px; } |
|
|
.barcode-input-group button { margin-top: 0; padding: 10px 12px; line-height: 1; font-size: 0.95rem;} |
|
|
|
|
|
|
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"> |
|
|
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
|
|
<img src="https://cdn-avatars.huggingface.co/v1/production/uploads/67c280ccb9d3dfdee58ecfdd/p2TVtSc6UQr0De1jDYBLk.jpeg" alt="Aikas_optom Logo" style="height: 40px; width: auto; border-radius: 4px;"> |
|
|
<h1><i class="fas fa-tools"></i> Админ-панель Aikas_optom</h1> |
|
|
</div> |
|
|
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #3F51B5;"><i class="fas fa-store"></i> Перейти в каталог</a> |
|
|
<a href="{{ url_for('sale_register') }}" class="button" style="background-color: #3F51B5;"><i class="fas fa-cash-register"></i> Касса</a> |
|
|
</div> |
|
|
|
|
|
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %} |
|
|
{% if messages %} |
|
|
{% for category, message in messages %} |
|
|
<div class="message {{ category }}">{{ message }}</div> |
|
|
{% endfor %} |
|
|
{% endif %} |
|
|
{% endwith %} |
|
|
|
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2></summary> |
|
|
<div class="form-content"> |
|
|
<div class="sync-buttons"> |
|
|
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');"> |
|
|
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button> |
|
|
</form> |
|
|
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');"> |
|
|
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button> |
|
|
</form> |
|
|
</div> |
|
|
<p style="font-size: 0.85rem; color: #607D8B;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p> |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
|
|
|
<div class="flex-container"> |
|
|
<div class="flex-item"> |
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-tags"></i> Управление категориями</h2></summary> |
|
|
<div class="form-content"> |
|
|
<details> |
|
|
<summary><i class="fas fa-plus-circle"></i> Добавить новую категорию</summary> |
|
|
<div class="form-content"> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="add_category"> |
|
|
<label for="add_category_name">Название новой категории:</label> |
|
|
<input type="text" id="add_category_name" name="category_name" required> |
|
|
<button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button> |
|
|
</form> |
|
|
</div> |
|
|
</details> |
|
|
|
|
|
<h3>Существующие категории:</h3> |
|
|
<input type="text" id="category-search-input" class="admin-search-input" placeholder="Поиск по категориям..."> |
|
|
<div class="item-list" id="admin-categories-list"> |
|
|
{% if categories %} |
|
|
{% for category in categories %} |
|
|
<div class="item category-item" style="display: flex; justify-content: space-between; align-items: center;" data-name="{{ category|lower }}"> |
|
|
<span>{{ category }}</span> |
|
|
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');"> |
|
|
<input type="hidden" name="action" value="delete_category"> |
|
|
<input type="hidden" name="category_name" value="{{ category }}"> |
|
|
<button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button> |
|
|
</form> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
<p>Категорий пока нет.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="flex-item"> |
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-chart-line"></i> Отчеты и статистика</h2></summary> |
|
|
<div class="form-content"> |
|
|
<h3>Общая статистика:</h3> |
|
|
<p><strong>Всего заказов/продаж:</strong> {{ stats.total_orders }}</p> |
|
|
<p><strong>Общая сумма продаж:</strong> {{ "%.2f"|format(stats.total_revenue) }} {{ currency_code }}</p> |
|
|
<p><strong>Количество товаров:</strong> {{ stats.total_products }}</p> |
|
|
<p><strong>В наличии:</strong> {{ stats.products_in_stock }} ({{ "%.1f"|format(stats.products_in_stock_percent) }}%)</p> |
|
|
<p><strong>Нет в наличии:</strong> {{ stats.products_out_of_stock }}</p> |
|
|
|
|
|
<h3>Топ-5 продаваемых товаров (по количеству):</h3> |
|
|
{% if stats.top_selling_products %} |
|
|
<ol style="padding-left: 20px;"> |
|
|
{% for product_name, quantity in stats.top_selling_products %} |
|
|
<li>{{ product_name }}: {{ quantity }} шт.</li> |
|
|
{% endfor %} |
|
|
</ol> |
|
|
{% else %} |
|
|
<p>Нет данных о продажах.</p> |
|
|
{% endif %} |
|
|
|
|
|
<h3>Товары с низким остатком (менее 10 шт.):</h3> |
|
|
{% if stats.low_stock_products %} |
|
|
<ul style="padding-left: 20px;"> |
|
|
{% for product in stats.low_stock_products %} |
|
|
<li>{{ product.name }}: {{ product.stock }} шт.</li> |
|
|
{% endfor %} |
|
|
</ul> |
|
|
{% else %} |
|
|
<p>Все товары в достаточном количестве.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-box-open"></i> Управление товарами</h2></summary> |
|
|
<div class="form-content"> |
|
|
<details> |
|
|
<summary><i class="fas fa-plus-circle"></i> Добавить новый товар</summary> |
|
|
<div class="form-content"> |
|
|
<form method="POST" enctype="multipart/form-data"> |
|
|
<input type="hidden" name="action" value="add_product"> |
|
|
<label for="add_name">Название товара *:</label> |
|
|
<input type="text" id="add_name" name="name" required> |
|
|
<label for="add_price">Цена ({{ currency_code }}) *:</label> |
|
|
<input type="number" id="add_price" name="price" step="0.01" min="0" required> |
|
|
<label for="add_stock">Начальный остаток на складе *:</label> |
|
|
<input type="number" id="add_stock" name="stock" min="0" value="0" required> |
|
|
<label for="add_description">Описание:</label> |
|
|
<textarea id="add_description" name="description" rows="4"></textarea> |
|
|
<label for="add_category">Категория:</label> |
|
|
<select id="add_category" name="category"> |
|
|
<option value="Без категории">Без категории</option> |
|
|
{% for category in categories %} |
|
|
<option value="{{ category }}">{{ category }}</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label for="add_photos">Фотографии (до 10 шт.):</label> |
|
|
<input type="file" id="add_photos" name="photos" accept="image/*" multiple> |
|
|
|
|
|
<label for="add_barcode">Штрихкод:</label> |
|
|
<div class="barcode-input-group"> |
|
|
<input type="text" id="add_barcode" name="barcode" placeholder="Отсканируйте или введите"> |
|
|
<button type="button" class="button" onclick="scanBarcode('add_barcode')"><i class="fas fa-barcode"></i> Сканировать</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<label>Цвета/Варианты (оставьте пустым, если нет):</label> |
|
|
<div id="add-color-inputs"> |
|
|
<div class="color-input-group"> |
|
|
<input type="text" name="colors" placeholder="Например: Розовый"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> |
|
|
</div> |
|
|
</div> |
|
|
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button> |
|
|
<br> |
|
|
<div style="margin-top: 5px;"> |
|
|
<input type="checkbox" id="add_is_top" name="is_top"> |
|
|
<label for="add_is_top" class="inline-label">Топ товар (показывать наверху)</label> |
|
|
</div> |
|
|
<br> |
|
|
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button> |
|
|
</form> |
|
|
</div> |
|
|
</details> |
|
|
|
|
|
<h3>Список товаров:</h3> |
|
|
<input type="text" id="product-search-input" class="admin-search-input" placeholder="Поиск товаров по названию, описанию или штрихкоду..."> |
|
|
<div class="item-list" id="admin-products-list"> |
|
|
{% if products %} |
|
|
{% for product in products %} |
|
|
<div class="item product-item" |
|
|
data-name="{{ product['name']|lower }}" |
|
|
data-description="{{ product.get('description', '')|lower }}" |
|
|
data-barcode="{{ product.get('barcode', '')|lower }}" |
|
|
> |
|
|
<div style="display: flex; gap: 15px; align-items: flex-start;"> |
|
|
<div class="photo-preview" style="flex-shrink: 0;"> |
|
|
{% if product.get('photos') and product['photos']|length > 0 %} |
|
|
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото"> |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото"> |
|
|
</a> |
|
|
{% else %} |
|
|
<img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото"> |
|
|
{% endif %} |
|
|
</div> |
|
|
<div style="flex-grow: 1;"> |
|
|
<h3 style="margin-top: 0; margin-bottom: 5px; color: #263238;"> |
|
|
{{ product['name'] }} |
|
|
{% if product.get('in_stock', True) %} |
|
|
<span class="status-indicator in-stock">В наличии</span> |
|
|
{% else %} |
|
|
<span class="status-indicator out-of-stock">Нет в наличии</span> |
|
|
{% endif %} |
|
|
{% if product.get('is_top', False) %} |
|
|
<span class="status-indicator top-product"><i class="fas fa-star"></i> Топ</span> |
|
|
{% endif %} |
|
|
</h3> |
|
|
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p> |
|
|
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p> |
|
|
<p><strong>Остаток:</b> {{ product.get('stock', 0) }} шт.</p> |
|
|
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p> |
|
|
{% if product.get('barcode') %} |
|
|
<p><strong>Штрихкод:</strong> {{ product['barcode'] }}</p> |
|
|
{% endif %} |
|
|
{% if product.get('colors') and product['colors']|select('ne', '')|list|length > 0 %} |
|
|
<p><strong>Цвета/Вар-ты:</strong> {{ product['colors']|select('ne', '')|join(', ') }}</p> |
|
|
{% endif %} |
|
|
{% if product.get('photos') and product['photos']|length > 1 %} |
|
|
<p style="font-size: 0.8rem; color: #607D8B;">(Всего фото: {{ product['photos']|length }})</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="item-actions"> |
|
|
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ product['id'] }}')"><i class="fas fa-edit"></i> Редактировать</button> |
|
|
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');"> |
|
|
<input type="hidden" name="action" value="delete_product"> |
|
|
<input type="hidden" name="product_id" value="{{ product['id'] }}"> |
|
|
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button> |
|
|
</form> |
|
|
</div> |
|
|
|
|
|
<div id="edit-form-{{ product['id'] }}" class="edit-form-container"> |
|
|
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4> |
|
|
<form method="POST" enctype="multipart/form-data"> |
|
|
<input type="hidden" name="action" value="edit_product"> |
|
|
<input type="hidden" name="product_id" value="{{ product['id'] }}"> |
|
|
<label>Название *:</label> |
|
|
<input type="text" name="name" value="{{ product['name'] }}" required> |
|
|
<label>Цена ({{ currency_code }}) *:</label> |
|
|
<input type="number" name="price" step="0.01" min="0" value="{{ product['price'] }}" required> |
|
|
<label>Остаток на складе *:</label> |
|
|
<input type="number" name="stock" min="0" value="{{ product.get('stock', 0) }}" required> |
|
|
<label>Описание:</label> |
|
|
<textarea name="description" rows="4">{{ product.get('description', '') }}</textarea> |
|
|
<label>Категория:</label> |
|
|
<select name="category"> |
|
|
<option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option> |
|
|
{% for category in categories %} |
|
|
<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label> |
|
|
<input type="file" name="photos" accept="image/*" multiple> |
|
|
{% if product.get('photos') %} |
|
|
<p style="font-size: 0.85rem; margin-top: 5px;">Текущие фото:</p> |
|
|
<div class="photo-preview"> |
|
|
{% for photo in product['photos'] %} |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" alt="Фото {{ loop.index }}"> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% endif %} |
|
|
|
|
|
<label for="edit_barcode_{{ product['id'] }}">Штрихкод:</label> |
|
|
<div class="barcode-input-group"> |
|
|
<input type="text" id="edit_barcode_{{ product['id'] }}" name="barcode" value="{{ product.get('barcode', '') }}" placeholder="Отсканируйте или введите"> |
|
|
<button type="button" class="button" onclick="scanBarcode('edit_barcode_{{ product['id'] }}')"><i class="fas fa-barcode"></i> Сканировать</button> |
|
|
</div> |
|
|
|
|
|
<label>Цвета/Варианты:</label> |
|
|
<div id="edit-color-inputs-{{ product['id'] }}"> |
|
|
{% 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() %} |
|
|
<div class="color-input-group"> |
|
|
<input type="text" name="colors" value="{{ color }}"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> |
|
|
</div> |
|
|
{% endif %} |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
<div class="color-input-group"> |
|
|
<input type="text" name="colors" placeholder="Например: Цвет"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> |
|
|
</div> |
|
|
{% endif %} |
|
|
</div> |
|
|
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ product['id'] }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button> |
|
|
<br> |
|
|
<div style="margin-top: 5px;"> |
|
|
<input type="checkbox" id="edit_is_top_{{ product['id'] }}" name="is_top" {% if product.get('is_top', False) %}checked{% endif %}> |
|
|
<label for="edit_is_top_{{ product['id'] }}" class="inline-label">Топ товар</label> |
|
|
</div> |
|
|
<br> |
|
|
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% else %} |
|
|
<p>Товаров пока нет.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
|
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-boxes"></i> Управление остатками</h2></summary> |
|
|
<div class="form-content"> |
|
|
<form method="POST"> |
|
|
<input type="hidden" name="action" value="adjust_stock"> |
|
|
<label for="adjust_product_select">Выберите товар:</label> |
|
|
<select id="adjust_product_select" name="product_id" required> |
|
|
<option value="">-- Выберите товар --</option> |
|
|
{% for product in products %} |
|
|
<option value="{{ product.id }}">{{ product.name }} (Текущий остаток: {{ product.get('stock', 0) }} шт.)</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<label for="adjust_quantity">Количество для корректировки:</label> |
|
|
<input type="number" id="adjust_quantity" name="quantity" min="1" value="1" required> |
|
|
<label>Тип корректировки:</label> |
|
|
<input type="radio" id="adjust_add" name="adjustment_type" value="add" checked> |
|
|
<label for="adjust_add" class="inline-label">Добавить</label> |
|
|
<input type="radio" id="adjust_subtract" name="adjustment_type" value="subtract"> |
|
|
<label for="adjust_subtract" class="inline-label">Вычесть</label> |
|
|
<br> |
|
|
<button type="submit" class="button"><i class="fas fa-exchange-alt"></i> Применить корректировку</button> |
|
|
</form> |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
|
|
|
<div class="section"> |
|
|
<details> |
|
|
<summary><h2><i class="fas fa-clipboard-list"></i> Управление заказами</h2></summary> |
|
|
<div class="form-content"> |
|
|
<input type="text" id="order-search-input" class="admin-search-input" placeholder="Поиск по ID, клиенту или товару..."> |
|
|
<div id="admin-orders-list" class="item-list"> |
|
|
</div> |
|
|
</div> |
|
|
</details> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Barcode Scanner Modal --> |
|
|
<div id="scannerModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeScannerModal()" aria-label="Закрыть сканер">×</span> |
|
|
<h2>Сканирование штрихкода</h2> |
|
|
<div id="interactive" class="viewport"> |
|
|
<video></video> |
|
|
<div class="scan-line"></div> |
|
|
</div> |
|
|
<p id="scanner-message">Наведите камеру на штрихкод...</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<script> |
|
|
let codeReader = null; |
|
|
let currentBarcodeInputId = null; |
|
|
|
|
|
function toggleEditForm(formId) { |
|
|
const formContainer = document.getElementById(formId); |
|
|
if (formContainer) { |
|
|
formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none'; |
|
|
} |
|
|
} |
|
|
|
|
|
function addColorInput(containerId) { |
|
|
const container = document.getElementById(containerId); |
|
|
if (container) { |
|
|
const newInputGroup = document.createElement('div'); |
|
|
newInputGroup.className = 'color-input-group'; |
|
|
newInputGroup.innerHTML = ` |
|
|
<input type="text" name="colors" placeholder="Новый цвет/вариант"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> |
|
|
`; |
|
|
container.appendChild(newInputGroup); |
|
|
const newInput = newInputGroup.querySelector('input[name="colors"]'); |
|
|
if (newInput) { |
|
|
newInput.focus(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function removeColorInput(button) { |
|
|
const group = button.closest('.color-input-group'); |
|
|
if (group) { |
|
|
const container = group.parentNode; |
|
|
group.remove(); |
|
|
// Optional: add a placeholder if all color inputs are removed |
|
|
if (container && container.children.length === 0) { |
|
|
const placeholderGroup = document.createElement('div'); |
|
|
placeholderGroup.className = 'color-input-group'; |
|
|
placeholderGroup.innerHTML = ` |
|
|
<input type="text" name="colors" placeholder="Например: Цвет"> |
|
|
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button> |
|
|
`; |
|
|
container.appendChild(placeholderGroup); |
|
|
} |
|
|
} else { |
|
|
console.warn("Could not find parent .color-input-group for remove button"); |
|
|
} |
|
|
} |
|
|
|
|
|
// Barcode Scanner Functions |
|
|
async function scanBarcode(inputId) { |
|
|
currentBarcodeInputId = inputId; |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
const videoElement = scannerModal.querySelector('video'); |
|
|
const scannerMessage = document.getElementById('scanner-message'); |
|
|
|
|
|
if (!scannerModal || !videoElement || !scannerMessage) { |
|
|
console.error("Scanner modal elements not found."); |
|
|
return; |
|
|
} |
|
|
|
|
|
scannerMessage.textContent = 'Запуск камеры...'; |
|
|
scannerModal.style.display = 'flex'; |
|
|
document.body.style.overflow = 'hidden'; // Prevent scrolling |
|
|
|
|
|
try { |
|
|
codeReader = new ZXing.BrowserMultiFormatReader(); |
|
|
// Prefer rear camera |
|
|
const devices = await codeReader.listVideoInputDevices(); |
|
|
const rearCamera = devices.find(device => device.label.toLowerCase().includes('rear')) || devices[0]; |
|
|
|
|
|
|
|
|
codeReader.decodeFromVideoDevice(rearCamera.deviceId, videoElement, (result, err) => { |
|
|
if (result) { |
|
|
console.log('Scanned barcode:', result.text); |
|
|
document.getElementById(currentBarcodeInputId).value = result.text; |
|
|
scannerMessage.textContent = 'Штрихкод отсканирован: ' + result.text; |
|
|
closeScannerModal(); |
|
|
} |
|
|
if (err && !(err instanceof ZXing.NotFoundException)) { |
|
|
// Log error but keep trying if it's not just "not found" |
|
|
console.error(err); |
|
|
scannerMessage.textContent = 'Ошибка сканирования: ' + err.message; |
|
|
} |
|
|
}); |
|
|
scannerMessage.textContent = 'Наведите камеру на штрихкод...'; |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error accessing camera:', error); |
|
|
scannerMessage.textContent = 'Не удалось запустить камеру: ' + error.message; |
|
|
// Optionally auto-close modal if camera fails |
|
|
// setTimeout(closeScannerModal, 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
function closeScannerModal() { |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
if (codeReader) { |
|
|
codeReader.reset(); |
|
|
codeReader = null; |
|
|
} |
|
|
if (scannerModal) { |
|
|
scannerModal.style.display = 'none'; |
|
|
const anyModalOpen = document.querySelector('.modal[style*="display: block"]'); |
|
|
if (!anyModalOpen) { |
|
|
document.body.style.overflow = 'auto'; |
|
|
} |
|
|
} |
|
|
currentBarcodeInputId = null; |
|
|
} |
|
|
|
|
|
|
|
|
const allProducts = {{ products|tojson }}; |
|
|
const allOrders = {{ display_orders|tojson }}; |
|
|
|
|
|
function filterAdminProducts() { |
|
|
const searchTerm = document.getElementById('product-search-input').value.toLowerCase().trim(); |
|
|
document.querySelectorAll('#admin-products-list .product-item').forEach(productElement => { |
|
|
const name = productElement.dataset.name; |
|
|
const description = productElement.dataset.description; |
|
|
const barcode = productElement.dataset.barcode; |
|
|
|
|
|
const matches = name.includes(searchTerm) || description.includes(searchTerm) || barcode.includes(searchTerm); |
|
|
|
|
|
if (matches) { |
|
|
productElement.style.display = 'block'; |
|
|
} else { |
|
|
productElement.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function filterAdminCategories() { |
|
|
const searchTerm = document.getElementById('category-search-input').value.toLowerCase().trim(); |
|
|
document.querySelectorAll('#admin-categories-list .category-item').forEach(categoryElement => { |
|
|
const categoryName = categoryElement.dataset.name; |
|
|
if (categoryName.includes(searchTerm)) { |
|
|
categoryElement.style.display = 'flex'; |
|
|
} else { |
|
|
categoryElement.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function renderAdminOrders() { |
|
|
const orderListContainer = document.getElementById('admin-orders-list'); |
|
|
orderListContainer.innerHTML = ''; |
|
|
if (allOrders.length === 0) { |
|
|
orderListContainer.innerHTML = '<p>Заказов пока нет.</p>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
allOrders.forEach(order => { |
|
|
const orderDiv = document.createElement('div'); |
|
|
orderDiv.className = 'item order-item'; |
|
|
orderDiv.dataset.orderId = order.id.toLowerCase(); |
|
|
orderDiv.dataset.customerName = (order.customer_name || '').toLowerCase(); |
|
|
orderDiv.dataset.products = order.cart.map(item => item.name.toLowerCase()).join(' '); |
|
|
|
|
|
let itemsHtml = order.cart.map(item => { |
|
|
const colorText = item.color && item.color !== 'N/A' ? ` (${item.color})` : ''; |
|
|
return `<li>${item.name}${colorText} × ${item.quantity} (${item.price.toFixed(2)} {{ currency_code }} each)</li>`; |
|
|
}).join(''); |
|
|
|
|
|
orderDiv.innerHTML = ` |
|
|
<h3><i class="fas fa-receipt"></i> Заказ №${order.id}</h3> |
|
|
<p><strong>Дата:</strong> ${order.created_at}</p> |
|
|
<p><strong>Клиент:</strong> ${order.customer_name || 'Анонимный'}</p> |
|
|
<p><strong>Контакт:</strong> ${order.customer_contact || 'Не указан'}</p> |
|
|
<p><strong>Статус:</strong> ${order.status}</p> |
|
|
<p><strong>Сумма:</strong> ${order.total_price.toFixed(2)} {{ currency_code }}</p> |
|
|
<details> |
|
|
<summary>Состав заказа</summary> |
|
|
<ul style="padding-left: 20px; list-style: disc;">${itemsHtml}</ul> |
|
|
</details> |
|
|
`; |
|
|
orderListContainer.appendChild(orderDiv); |
|
|
}); |
|
|
filterAdminOrders(); |
|
|
} |
|
|
|
|
|
function filterAdminOrders() { |
|
|
const searchTerm = document.getElementById('order-search-input').value.toLowerCase().trim(); |
|
|
document.querySelectorAll('#admin-orders-list .order-item').forEach(orderElement => { |
|
|
const orderId = orderElement.dataset.orderId; |
|
|
const customerName = orderElement.dataset.customerName; |
|
|
const productsInOrder = orderElement.dataset.products; |
|
|
|
|
|
// Combine all searchable data into one string for easier check |
|
|
const searchableData = `${orderId} ${customerName} ${productsInOrder}`; |
|
|
|
|
|
if (searchableData.includes(searchTerm)) { |
|
|
orderElement.style.display = 'block'; |
|
|
} else { |
|
|
orderElement.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
document.getElementById('product-search-input').addEventListener('input', filterAdminProducts); |
|
|
document.getElementById('category-search-input').addEventListener('input', filterAdminCategories); |
|
|
document.getElementById('order-search-input').addEventListener('input', filterAdminOrders); |
|
|
|
|
|
filterAdminProducts(); |
|
|
filterAdminCategories(); |
|
|
renderAdminOrders(); |
|
|
|
|
|
// Ensure scanner modal can be closed by clicking outside |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
if (scannerModal) { |
|
|
scannerModal.addEventListener('click', function(event) { |
|
|
// Check if the click target is the modal itself, not its content |
|
|
if (event.target === scannerModal) { |
|
|
closeScannerModal(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
// Add keydown listener for Escape specifically for the scanner modal |
|
|
window.addEventListener('keydown', function(event) { |
|
|
if (event.key === 'Escape' && scannerModal && scannerModal.style.display === 'flex') { |
|
|
closeScannerModal(); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
SALE_TEMPLATE = ''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Касса - Aikas_optom</title> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0"></script> |
|
|
<style> |
|
|
body { font-family: 'Poppins', sans-serif; background-color: #F5F5F5; color: #263238; padding: 20px; line-height: 1.6; } |
|
|
.container { max-width: 1400px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } |
|
|
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #CFD8DC; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;} |
|
|
h1, h2, h3 { font-weight: 600; color: #3F51B5; margin-bottom: 15px; } |
|
|
h1 { font-size: 1.8rem; } |
|
|
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } |
|
|
h3 { font-size: 1.2rem; color: #303F9F; margin-top: 20px; } |
|
|
.button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #3F51B5; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;} |
|
|
.button:hover { background-color: #303F9F; } |
|
|
.button:active { transform: scale(0.98); } |
|
|
.button i { font-size: 1.2rem; } |
|
|
.add-button { background-color: #3F51B5; } |
|
|
.add-button:hover { background-color: #303F9F; } |
|
|
.delete-button { background-color: #f56565; } |
|
|
.delete-button:hover { background-color: #e53e3e; } |
|
|
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;} |
|
|
.message.success { background-color: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7;} |
|
|
.message.error { background-color: #ffebee; color: #c62828; border: 1px solid #ef9a9a;} |
|
|
.message.warning { background-color: #fffde7; color: #ef6c00; border: 1px solid #ffcc80; } |
|
|
input[type="text"], input[type="number"], input[type="tel"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #CFD8DC; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; background-color: #fff; } |
|
|
input:focus, textarea:focus, select:focus { border-color: #3F51B5; outline: none; box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.1); } |
|
|
label { font-weight: 500; margin-top: 10px; display: block; color: #607D8B; font-size: 0.9rem;} |
|
|
|
|
|
.sale-management-container { |
|
|
display: flex; |
|
|
flex-direction: column; /* Default to column for mobile */ |
|
|
gap: 20px; |
|
|
margin-top: 20px; |
|
|
} |
|
|
|
|
|
.active-sales-panel { |
|
|
flex-shrink: 0; |
|
|
width: 100%; /* Full width on mobile */ |
|
|
background-color: #e8eaf6; |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 8px; |
|
|
padding: 20px; |
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.07); |
|
|
} |
|
|
.active-sales-panel h3 { margin-top: 0; color: #303F9F; } |
|
|
.sale-session-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 10px 15px; |
|
|
background-color: #fff; |
|
|
border: 1px solid #E0E0E0; |
|
|
border-radius: 6px; |
|
|
margin-bottom: 8px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
.sale-session-item.active { |
|
|
background-color: #3F51B5; |
|
|
color: white; |
|
|
border-color: #3F51B5; |
|
|
box-shadow: 0 2px 8px rgba(63, 81, 181, 0.2); |
|
|
} |
|
|
.sale-session-item:not(.active):hover { |
|
|
background-color: #e8eaf6; |
|
|
border-color: #3F51B5; |
|
|
} |
|
|
.sale-session-item button { margin-top: 0; padding: 5px 8px; font-size: 0.75rem; } |
|
|
|
|
|
.current-sale-area { |
|
|
flex-grow: 1; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 20px; |
|
|
width: 100%; /* Full width on mobile */ |
|
|
} |
|
|
.current-sale-area h3 { margin-top: 0; } |
|
|
.product-selection-panel, .product-details-for-sale, .sale-items-panel { |
|
|
background-color: #e8eaf6; |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 8px; |
|
|
padding: 20px; |
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.07); |
|
|
} |
|
|
|
|
|
.sale-search-barcode-group { display: flex; gap: 10px; align-items: flex-end; margin-bottom: 15px; } |
|
|
.sale-search-barcode-group input { flex-grow: 1; margin-bottom: 0;} |
|
|
.sale-search-barcode-group button { margin-top: 0; padding: 10px 12px; line-height: 1; font-size: 0.95rem; } |
|
|
|
|
|
|
|
|
#sale-product-search-results { |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 6px; |
|
|
margin-top: 10px; |
|
|
max-height: 200px; |
|
|
overflow-y: auto; |
|
|
background-color: #fff; |
|
|
display: none; /* Hidden by default */ |
|
|
} |
|
|
#sale-product-search-results > div { |
|
|
padding: 8px 10px; |
|
|
border-bottom: 1px solid #eee; |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
transition: background-color 0.2s ease; |
|
|
} |
|
|
#sale-product-search-results > div:hover { background-color: #e8eaf6; } |
|
|
#sale-product-search-results img { width: 50px; height: 50px; object-fit: contain; border-radius: 4px; border: 1px solid #CFD8DC;} |
|
|
|
|
|
.all-products-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); /* Smaller cards on small screens */ |
|
|
gap: 15px; |
|
|
margin-top: 20px; |
|
|
max-height: 600px; |
|
|
overflow-y: auto; |
|
|
padding-right: 10px; |
|
|
} |
|
|
.all-products-grid .product-card { |
|
|
background: #fff; |
|
|
border-radius: 10px; |
|
|
padding: 10px; |
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.05); |
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease; |
|
|
cursor: pointer; |
|
|
border: 1px solid #E0E0E0; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
text-align: center; |
|
|
} |
|
|
.all-products-grid .product-card:hover { |
|
|
transform: translateY(-3px); |
|
|
box-shadow: 0 4px 10px rgba(0,0,0,0.1); |
|
|
} |
|
|
.product-card img { |
|
|
width: 80px; |
|
|
height: 80px; |
|
|
object-fit: contain; |
|
|
border-radius: 5px; |
|
|
margin-bottom: 8px; |
|
|
border: 1px solid #CFD8DC; |
|
|
} |
|
|
.product-card strong { |
|
|
font-size: 0.9em; |
|
|
color: #263238; |
|
|
margin-bottom: 4px; |
|
|
white-space: nowrap; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
width: 100%; |
|
|
} |
|
|
.product-card .price { |
|
|
font-size: 0.8em; |
|
|
color: #303F9F; |
|
|
font-weight: bold; |
|
|
} |
|
|
.product-card .stock { |
|
|
font-size: 0.75em; |
|
|
color: #607D8B; |
|
|
} |
|
|
.product-card.out-of-stock { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
.product-card.out-of-stock .price { |
|
|
color: #c53030; |
|
|
} |
|
|
|
|
|
|
|
|
#selected-sale-product-info { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 15px; |
|
|
padding: 10px; |
|
|
background-color: #fff; |
|
|
border-radius: 8px; |
|
|
border: 1px dashed #CFD8DC; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
#selected-sale-product-image { width: 70px; height: 70px; object-fit: contain; border-radius: 5px; border: 1px solid #CFD8DC; } |
|
|
#selected-sale-product-name { margin: 0; font-weight: bold; font-size: 1.1em; color: #263238; } |
|
|
#selected-sale-product-price { margin: 5px 0 0; color: #303F9F; font-weight: bold; } |
|
|
#selected-sale-product-stock { margin: 0; font-size: 0.9em; color: #607D8B; } |
|
|
|
|
|
#sale-items-list { |
|
|
min-height: 100px; |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 6px; |
|
|
padding: 10px; |
|
|
background-color: #fff; |
|
|
} |
|
|
#sale-items-list > p#no-sale-items-message { text-align: center; color: #607D8B; padding: 10px; } |
|
|
|
|
|
.sale-item-row { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 8px 0; |
|
|
border-bottom: 1px dashed #CFD8DC; |
|
|
flex-wrap: wrap; /* Allow wrapping on small screens */ |
|
|
gap: 5px; /* Adjust gap for wrapped items */ |
|
|
} |
|
|
.sale-item-row:last-child { border-bottom: none; } |
|
|
|
|
|
.sale-item-detail-group { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 10px; |
|
|
flex-grow: 1; |
|
|
min-width: 200px; /* Ensure enough space for text and input */ |
|
|
} |
|
|
.sale-item-thumbnail { |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
object-fit: contain; |
|
|
border-radius: 4px; |
|
|
border: 1px solid #CFD8DC; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
.sale-item-text-info { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
justify-content: center; |
|
|
flex-grow: 1; |
|
|
} |
|
|
.sale-item-text-info strong { |
|
|
font-size: 0.95rem; |
|
|
color: #263238; |
|
|
white-space: nowrap; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
} |
|
|
.sale-item-qty-input { |
|
|
width: 80px; |
|
|
padding: 4px 8px; |
|
|
border: 1px solid #CFD8DC; |
|
|
border-radius: 4px; |
|
|
font-size: 0.85rem; |
|
|
box-sizing: border-box; |
|
|
margin-top: 5px; |
|
|
} |
|
|
.sale-item-total { |
|
|
font-weight: bold; |
|
|
text-align: right; |
|
|
font-size: 0.95rem; |
|
|
color: #303F9F; |
|
|
flex-shrink: 0; |
|
|
width: 100px; |
|
|
} |
|
|
.remove-sale-item-btn { |
|
|
background-color: #f56565; |
|
|
padding: 6px 10px; |
|
|
font-size: 0.8rem; |
|
|
margin: 0; |
|
|
line-height: 1; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
/* Barcode Scanner Modal (Same as Admin) */ |
|
|
#scannerModal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
z-index: 1003; /* Higher than other modals */ |
|
|
left: 0; |
|
|
top: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background-color: rgba(0,0,0,0.8); |
|
|
backdrop-filter: blur(8px); |
|
|
overflow: auto; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
flex-direction: column; |
|
|
color: white; |
|
|
} |
|
|
#scannerModal .modal-content { |
|
|
background: none; /* No background for scanner */ |
|
|
box-shadow: none; |
|
|
margin: auto; /* Center content */ |
|
|
max-width: 90%; |
|
|
width: 500px; /* Max width for video feed */ |
|
|
padding: 0; |
|
|
position: relative; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
} |
|
|
#scannerModal .close { |
|
|
position: absolute; |
|
|
top: 15px; |
|
|
right: 15px; |
|
|
z-index: 1004; |
|
|
color: rgba(255,255,255,0.8); |
|
|
font-size: 2rem; |
|
|
} |
|
|
#scannerModal .close:hover { color: white; } |
|
|
|
|
|
#interactive.viewport { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
height: 300px; /* Adjust height as needed */ |
|
|
text-align: center; |
|
|
overflow: hidden; |
|
|
border-radius: 10px; |
|
|
background-color: #333; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
} |
|
|
#interactive video { |
|
|
max-width: 100%; |
|
|
max-height: 100%; |
|
|
object-fit: cover; /* Cover the container */ |
|
|
display: block; |
|
|
} |
|
|
#interactive .scan-line { |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 2px; |
|
|
background: red; |
|
|
animation: scan-line 2s linear infinite; |
|
|
} |
|
|
|
|
|
|
|
|
@media (min-width: 768px) { |
|
|
.sale-management-container { |
|
|
flex-direction: row; /* Side-by-side on larger screens */ |
|
|
} |
|
|
.active-sales-panel { |
|
|
width: 250px; /* Fixed width for sidebar */ |
|
|
} |
|
|
.current-sale-area { |
|
|
flex-grow: 1; /* Takes remaining space */ |
|
|
} |
|
|
.all-products-grid { |
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); /* Original size on larger screens */ |
|
|
} |
|
|
.sale-item-row { |
|
|
flex-wrap: nowrap; /* Prevent wrapping on larger screens */ |
|
|
gap: 15px; /* Restore original gap */ |
|
|
} |
|
|
} |
|
|
@media (min-width: 480px) and (max-width: 767px) { |
|
|
.all-products-grid { |
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); /* Adjust for medium screens */ |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"> |
|
|
<div class="logo-title-container" style="display: flex; align-items: center; gap: 15px;"> |
|
|
<img src="https://cdn-avatars.huggingface.co/v1/production/uploads/67c280ccb9d3dfdee58ecfdd/p2TVtSc6UQr0De1jDYBLk.jpeg" alt="Aikas_optom Logo" style="height: 40px; width: auto; border-radius: 4px;"> |
|
|
<h1><i class="fas fa-cash-register"></i> Касса Aikas_optom</h1> |
|
|
</div> |
|
|
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #3F51B5;"><i class="fas fa-store"></i> Каталог</a> |
|
|
</div> |
|
|
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %} |
|
|
{% if messages %} |
|
|
{% for category, message in messages %} |
|
|
<div class="message {{ category }}">{{ message }}</div> |
|
|
{% endfor %} |
|
|
{% endif %} |
|
|
{% endwith %} |
|
|
|
|
|
<div class="sale-management-container"> |
|
|
<div class="active-sales-panel"> |
|
|
<h3>Активные кассы</h3> |
|
|
<div id="sale-sessions-list"> |
|
|
</div> |
|
|
<button class="button add-new-cash-button" onclick="createNewSaleSession()">+ Новая касса</button> |
|
|
</div> |
|
|
|
|
|
<div class="current-sale-area"> |
|
|
<h3>Текущая продажа (<span id="current-sale-session-name">Нет активной кассы</span>)</h3> |
|
|
|
|
|
<div class="product-selection-panel"> |
|
|
<h3>Поиск и выбор товара</h3> |
|
|
<div class="sale-search-barcode-group"> |
|
|
<input type="text" id="sale_product_search_input" placeholder="Название, описание или штрихкод..."> |
|
|
<button type="button" class="button" onclick="scanBarcodeForSale()"><i class="fas fa-barcode"></i></button> |
|
|
</div> |
|
|
|
|
|
<div id="sale-product-search-results" style="display: none;"></div> |
|
|
|
|
|
<div class="all-products-grid" id="all-products-for-sale"> |
|
|
{% for product in all_products %} |
|
|
<div class="product-card {% if product.get('stock', 0) <= 0 %}out-of-stock{% endif %}" |
|
|
data-product-id="{{ product['id'] }}" |
|
|
data-product-name="{{ product['name']|lower }}" |
|
|
data-product-description="{{ product.get('description', '')|lower }}" |
|
|
data-product-barcode="{{ product.get('barcode', '')|lower }}" |
|
|
onclick="selectProductForSale('{{ product['id'] }}')" |
|
|
> |
|
|
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="{{ product['name'] }}" loading="lazy"> |
|
|
<strong>{{ product['name'] }}</strong> |
|
|
<span class="price">{{ "%.2f"|format(product['price']) }} {{ currency_code }}</span> |
|
|
<span class="stock">Остаток: {{ product.get('stock', 0) }} шт.</span> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="product-details-for-sale"> |
|
|
<h3>Добавить в чек</h3> |
|
|
<div id="selected-sale-product-info" style="display: none;"> |
|
|
<img id="selected-sale-product-image" src="" alt="Изображение товара"> |
|
|
<div> |
|
|
<p id="selected-sale-product-name"></p> |
|
|
<p id="selected-sale-product-price"></p> |
|
|
<p id="selected-sale-product-stock"></p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<label for="sale_quantity_input">Количество:</label> |
|
|
<input type="number" id="sale_quantity_input" min="1" value="1" disabled> |
|
|
|
|
|
<label for="sale_color_select">Цвет/Вариант:</label> |
|
|
<select id="sale_color_select" disabled></select> |
|
|
|
|
|
<button type="button" class="button add-button" onclick="addSaleItemToCurrentSession()" id="add-to-sale-button" disabled><i class="fas fa-cart-plus"></i> Добавить в чек</button> |
|
|
</div> |
|
|
|
|
|
<div class="sale-items-panel"> |
|
|
<h3>Товары в текущей продаже:</h3> |
|
|
<div id="sale-items-list"> |
|
|
<p id="no-sale-items-message">Список пуст.</p> |
|
|
</div> |
|
|
|
|
|
<p style="text-align: right; font-size: 1.2rem; font-weight: bold; margin-top: 15px;"> |
|
|
Итоговая сумма: <span id="current-sale-total">0.00</span> {{ currency_code }} |
|
|
</p> |
|
|
|
|
|
<label for="customer_name">Имя покупателя (необязательно):</label> |
|
|
<input type="text" id="customer_name" placeholder="Например: Иван"> |
|
|
<label for="customer_contact">Контакт покупателя (телефон/WhatsApp, необязательно):</label> |
|
|
<input type="tel" id="customer_contact" placeholder="Например: +996777123456"> |
|
|
|
|
|
<button type="button" class="button add-button" style="margin-top: 20px;" id="confirm-sale-button" disabled onclick="finalizeCurrentSale()"> |
|
|
<i class="fas fa-check-circle"></i> Зарегистрировать продажу |
|
|
</button> |
|
|
<button type="button" class="button delete-button" style="margin-top: 10px;" id="clear-current-sale-button" disabled onclick="clearCurrentSaleSession()"> |
|
|
<i class="fas fa-trash"></i> Очистить текущий чек |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Barcode Scanner Modal --> |
|
|
<div id="scannerModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<span class="close" onclick="closeScannerModal()" aria-label="Закрыть сканер">×</span> |
|
|
<h2>Сканирование штрихкода</h2> |
|
|
<div id="interactive" class="viewport"> |
|
|
<video></video> |
|
|
<div class="scan-line"></div> |
|
|
</div> |
|
|
<p id="scanner-message">Наведите камеру на штрихкод...</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<script> |
|
|
const allProducts = {{ all_products|tojson }}; |
|
|
const repoId = '{{ repo_id }}'; |
|
|
const currencyCode = '{{ currency_code }}'; |
|
|
let currentSaleSessionId = null; |
|
|
let selectedProductForSale = null; // Holds the full product object from allProducts |
|
|
|
|
|
const saleProductSearchInput = document.getElementById('sale_product_search_input'); |
|
|
const saleProductSearchResults = document.getElementById('sale-product-search-results'); |
|
|
const allProductsForSaleGrid = document.getElementById('all-products-for-sale'); |
|
|
const selectedSaleProductInfoDiv = document.getElementById('selected-sale-product-info'); |
|
|
const selectedSaleProductName = document.getElementById('selected-sale-product-name'); |
|
|
const selectedSaleProductPrice = document.getElementById('selected-sale-product-price'); |
|
|
const selectedSaleProductStock = document.getElementById('selected-sale-product-stock'); |
|
|
const selectedSaleProductImage = document.getElementById('selected-sale-product-image'); |
|
|
const saleQuantityInput = document.getElementById('sale_quantity_input'); |
|
|
const saleColorSelect = document.getElementById('sale_color_select'); |
|
|
const addToSaleButton = document.getElementById('add-to-sale-button'); |
|
|
const customerNameInput = document.getElementById('customer_name'); |
|
|
const customerContactInput = document.getElementById('customer_contact'); |
|
|
const confirmSaleButton = document.getElementById('confirm-sale-button'); |
|
|
const clearCurrentSaleButton = document.getElementById('clear-current-sale-button'); |
|
|
|
|
|
let codeReader = null; |
|
|
|
|
|
|
|
|
function renderAllProductsForSale(productsToRender) { |
|
|
allProductsForSaleGrid.innerHTML = ''; |
|
|
if (productsToRender.length === 0) { |
|
|
allProductsForSaleGrid.innerHTML = '<p style="grid-column: 1 / -1; text-align: center; color: #607D8B;">Товары не найдены.</p>'; |
|
|
return; |
|
|
} |
|
|
productsToRender.forEach(product => { |
|
|
const photoUrl = product.photos && product.photos.length > 0 && product.photos[0] !== 'placeholder.png' |
|
|
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` |
|
|
: `https://via.placeholder.com/80x80.png?text=No+Image`; |
|
|
|
|
|
const productCard = document.createElement('div'); |
|
|
productCard.className = `product-card ${product.stock <= 0 ? 'out-of-stock' : ''}`; |
|
|
productCard.dataset.productId = product.id; |
|
|
productCard.dataset.productName = product.name.toLowerCase(); |
|
|
productCard.dataset.productDescription = product.description ? product.description.toLowerCase() : ''; |
|
|
productCard.dataset.productBarcode = product.barcode ? product.barcode.toLowerCase() : ''; |
|
|
|
|
|
// Prevent click on out-of-stock items |
|
|
if (product.stock > 0) { |
|
|
productCard.onclick = () => selectProductForSale(product.id); |
|
|
} else { |
|
|
productCard.style.cursor = 'not-allowed'; |
|
|
} |
|
|
|
|
|
|
|
|
productCard.innerHTML = ` |
|
|
<img src="${photoUrl}" alt="${product.name}" loading="lazy"> |
|
|
<strong>${product.name}</strong> |
|
|
<span class="price">${product.price.toFixed(2)} ${currencyCode}</span> |
|
|
<span class="stock">Остаток: ${product.stock} шт.</span> |
|
|
`; |
|
|
allProductsForSaleGrid.appendChild(productCard); |
|
|
}); |
|
|
} |
|
|
|
|
|
function filterProductsForSaleGrid() { |
|
|
const searchTerm = saleProductSearchInput.value.toLowerCase().trim(); |
|
|
const filteredProducts = allProducts.filter(p => { |
|
|
const name = p.name.toLowerCase(); |
|
|
const description = p.description ? p.description.toLowerCase() : ''; |
|
|
const barcode = p.barcode ? p.barcode.toLowerCase() : ''; |
|
|
return name.includes(searchTerm) || description.includes(searchTerm) || barcode.includes(searchTerm); |
|
|
}); |
|
|
renderAllProductsForSale(filteredProducts); |
|
|
} |
|
|
|
|
|
|
|
|
// Barcode Scanner Functions |
|
|
async function scanBarcodeForSale() { |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
const videoElement = scannerModal.querySelector('video'); |
|
|
const scannerMessage = document.getElementById('scanner-message'); |
|
|
|
|
|
if (!scannerModal || !videoElement || !scannerMessage) { |
|
|
console.error("Scanner modal elements not found."); |
|
|
alert("Ошибка: Не удалось инициализировать сканер."); |
|
|
return; |
|
|
} |
|
|
|
|
|
scannerMessage.textContent = 'Запуск камеры...'; |
|
|
scannerModal.style.display = 'flex'; |
|
|
document.body.style.overflow = 'hidden'; // Prevent scrolling |
|
|
|
|
|
try { |
|
|
codeReader = new ZXing.BrowserMultiFormatReader(); |
|
|
// Prefer rear camera |
|
|
const devices = await codeReader.listVideoInputDevices(); |
|
|
const rearCamera = devices.find(device => device.label.toLowerCase().includes('rear')) || devices[0]; |
|
|
|
|
|
if (!rearCamera) { |
|
|
scannerMessage.textContent = 'Камера не найдена.'; |
|
|
alert("Ошибка: Камера не найдена."); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
codeReader.decodeFromVideoDevice(rearCamera.deviceId, videoElement, (result, err) => { |
|
|
if (result) { |
|
|
console.log('Scanned barcode:', result.text); |
|
|
const scannedBarcode = result.text; |
|
|
scannerMessage.textContent = 'Штрихкод отсканирован: ' + scannedBarcode; |
|
|
closeScannerModal(); |
|
|
// Now, find the product by this barcode |
|
|
findProductByBarcode(scannedBarcode); |
|
|
|
|
|
} |
|
|
if (err && !(err instanceof ZXing.NotFoundException)) { |
|
|
// Log error but keep trying if it's not just "not found" |
|
|
console.error(err); |
|
|
scannerMessage.textContent = 'Ошибка сканирования: ' + err.message; |
|
|
} |
|
|
}); |
|
|
scannerMessage.textContent = 'Наведите камеру на штрихкод...'; |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error accessing camera:', error); |
|
|
scannerMessage.textContent = 'Не удалось запустить камеру: ' + error.message; |
|
|
alert("Ошибка доступа к камере."); |
|
|
// Optionally auto-close modal if camera fails |
|
|
// setTimeout(closeScannerModal, 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
function closeScannerModal() { |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
if (codeReader) { |
|
|
codeReader.reset(); |
|
|
codeReader = null; |
|
|
} |
|
|
if (scannerModal) { |
|
|
scannerModal.style.display = 'none'; |
|
|
const anyModalOpen = document.querySelector('.modal[style*="display: block"]'); |
|
|
if (!anyModalOpen) { |
|
|
document.body.style.overflow = 'auto'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
async function findProductByBarcode(barcode) { |
|
|
if (!barcode) return; |
|
|
|
|
|
// Simulate API call to find product by barcode (or do it client-side for this example) |
|
|
// In a real app, you'd fetch('/api/find_product_by_barcode/' + barcode) |
|
|
const product = allProducts.find(p => p.barcode === barcode); |
|
|
|
|
|
if (product) { |
|
|
if (product.stock <= 0) { |
|
|
alert(`Товар "${product.name}" со штрихкодом ${barcode} отсутствует на складе.`); |
|
|
// Clear selected product UI |
|
|
selectedProductForSale = null; |
|
|
selectedSaleProductInfoDiv.style.display = 'none'; |
|
|
saleQuantityInput.disabled = true; |
|
|
saleColorSelect.disabled = true; |
|
|
addToSaleButton.disabled = true; |
|
|
return; |
|
|
} |
|
|
selectProductForSale(product.id); // Select the found product |
|
|
} else { |
|
|
alert(`Товар со штрихкодом "${barcode}" не найден.`); |
|
|
// Clear selected product UI |
|
|
selectedProductForSale = null; |
|
|
selectedSaleProductInfoDiv.style.display = 'none'; |
|
|
saleQuantityInput.disabled = true; |
|
|
saleColorSelect.disabled = true; |
|
|
addToSaleButton.disabled = true; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function selectProductForSale(productId) { |
|
|
const product = allProducts.find(p => p.id === productId); |
|
|
if (!product) { |
|
|
alert("Ошибка: товар не найден."); |
|
|
return; |
|
|
} |
|
|
if (product.stock <= 0) { |
|
|
alert(`Товар "${product.name}" отсутствует на складе.`); |
|
|
// Clear any previously selected product info display |
|
|
selectedProductForSale = null; |
|
|
selectedSaleProductInfoDiv.style.display = 'none'; |
|
|
saleQuantityInput.disabled = true; |
|
|
saleColorSelect.disabled = true; |
|
|
addToSaleButton.disabled = true; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
selectedProductForSale = product; |
|
|
selectedSaleProductName.textContent = product.name; |
|
|
selectedSaleProductPrice.textContent = `${product.price.toFixed(2)} ${currencyCode}`; |
|
|
selectedSaleProductStock.textContent = `Остаток: ${product.stock} шт.`; |
|
|
selectedSaleProductImage.src = product.photos && product.photos.length > 0 && product.photos[0] !== 'placeholder.png' |
|
|
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${product.photos[0]}` |
|
|
: `https://via.placeholder.com/70x70.png?text=N/A`; |
|
|
selectedSaleProductInfoDiv.style.display = 'flex'; |
|
|
|
|
|
saleQuantityInput.value = 1; |
|
|
saleQuantityInput.max = product.stock; // Set max to product stock |
|
|
saleQuantityInput.disabled = false; |
|
|
saleQuantityInput.oninput = () => { |
|
|
let val = parseInt(saleQuantityInput.value); |
|
|
if (isNaN(val) || val < 1) val = 1; |
|
|
|
|
|
// Check quantity against available stock *in the current session* |
|
|
let currentQtyInSession = openSaleSessionsData && openSaleSessionsData[currentSaleSessionId] |
|
|
? openSaleSessionsData[currentSaleSessionId].cart_items.reduce((sum, item) => { |
|
|
// Sum quantity for this product and selected color |
|
|
if (item.product_id === selectedProductForSale.id && item.color === saleColorSelect.value) { |
|
|
return sum + item.quantity; |
|
|
} |
|
|
return sum; |
|
|
}, 0) : 0; |
|
|
|
|
|
// Calculate remaining stock considering other sessions |
|
|
let totalReservedInOtherSessions = 0; |
|
|
if(openSaleSessionsData){ |
|
|
for(const sessionId in openSaleSessionsData){ |
|
|
if(sessionId !== currentSaleSessionId){ |
|
|
totalReservedInOtherSessions += openSaleSessionsData[sessionId].cart_items.reduce((sum, item) => { |
|
|
if (item.product_id === selectedProductForSale.id && item.color === saleColorSelect.value) { |
|
|
return sum + item.quantity; |
|
|
} |
|
|
return sum; |
|
|
}, 0); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const effectiveStock = selectedProductForSale.stock - totalReservedInOtherSessions; |
|
|
|
|
|
if (effectiveStock < currentQtyInSession + val) { |
|
|
val = effectiveStock - currentQtyInSession; |
|
|
if (val < 0) val = 0; // Should not happen if checks are correct, but safety |
|
|
saleQuantityInput.value = val; |
|
|
alert(`Недостаточно товара "${selectedProductForSale.name}" (цвет: ${saleColorSelect.value}). Доступно на складе: ${selectedProductForSale.stock} шт. (Из них ${totalReservedInOtherSessions} шт. зарезервированы в других кассах. Уже в этой кассе: ${currentQtyInSession}). Вы можете добавить не более ${val} шт.`); |
|
|
} else { |
|
|
saleQuantityInput.value = val; |
|
|
} |
|
|
}; |
|
|
|
|
|
const colors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : []; |
|
|
saleColorSelect.innerHTML = ''; |
|
|
if (colors.length > 0) { |
|
|
colors.forEach(color => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = color.trim(); |
|
|
option.text = color.trim(); |
|
|
saleColorSelect.appendChild(option); |
|
|
}); |
|
|
saleColorSelect.style.display = 'block'; |
|
|
document.querySelector('label[for="sale_color_select"]').style.display = 'block'; |
|
|
} else { |
|
|
const option = document.createElement('option'); |
|
|
option.value = 'N/A'; |
|
|
option.text = 'N/A'; |
|
|
saleColorSelect.appendChild(option); |
|
|
saleColorSelect.style.display = 'none'; |
|
|
document.querySelector('label[for="sale_color_select"]').style.display = 'none'; |
|
|
} |
|
|
saleColorSelect.disabled = false; |
|
|
addToSaleButton.disabled = false; |
|
|
// Clear search input and grid after selection? Or just filter grid? |
|
|
// saleProductSearchInput.value = product.name; // Optional: put name back in search |
|
|
// filterProductsForSaleGrid(); // Optional: re-filter grid |
|
|
} |
|
|
|
|
|
async function addSaleItemToCurrentSession() { |
|
|
if (!currentSaleSessionId) { |
|
|
alert("Пожалуйста, выберите активную кассу или создайте новую."); |
|
|
return; |
|
|
} |
|
|
if (!selectedProductForSale) { |
|
|
alert("Пожалуйста, выберите товар для добавления в чек."); |
|
|
return; |
|
|
} |
|
|
|
|
|
const productId = selectedProductForSale.id; |
|
|
const quantity = parseInt(saleQuantityInput.value); |
|
|
const color = saleColorSelect.value; |
|
|
|
|
|
if (isNaN(quantity) || quantity <= 0) { |
|
|
alert("Пожалуйста, укажите корректное количество (больше 0)."); |
|
|
saleQuantityInput.focus(); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await fetch('/sale/add_item', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
session_id: currentSaleSessionId, |
|
|
product_id: productId, |
|
|
quantity: quantity, |
|
|
color: color |
|
|
}) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
openSaleSessionsData = result.active_sessions_data; // Update local session data |
|
|
renderCurrentSaleItems(result.current_sale_cart); |
|
|
updateSaleButtons(); |
|
|
selectedProductForSale = null; // Deselect after adding |
|
|
selectedSaleProductInfoDiv.style.display = 'none'; |
|
|
saleQuantityInput.disabled = true; |
|
|
saleColorSelect.disabled = true; |
|
|
addToSaleButton.disabled = true; |
|
|
saleProductSearchInput.value = ''; // Clear search input |
|
|
filterProductsForSaleGrid(); // Refresh product grid based on current stock/availability |
|
|
showNotification(`Товар добавлен в кассу ${currentSaleSessionId.substring(0, 8)}.`); |
|
|
} else { |
|
|
alert(result.error || "Ошибка при добавлении товара."); |
|
|
// If stock error, update max quantity based on error response? |
|
|
if (result.max_quantity !== undefined) { |
|
|
saleQuantityInput.max = result.max_quantity; |
|
|
saleQuantityInput.value = result.max_quantity; // Set to max available |
|
|
saleQuantityInput.focus(); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при добавлении товара."); |
|
|
} |
|
|
} |
|
|
|
|
|
async function removeSaleItemFromCurrentSession(tempId) { |
|
|
if (!currentSaleSessionId) return; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/sale/remove_item', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
session_id: currentSaleSessionId, |
|
|
temp_id: tempId |
|
|
}) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
openSaleSessionsData = result.active_sessions_data; // Update local session data |
|
|
renderCurrentSaleItems(result.current_sale_cart); |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); // Refresh product grid |
|
|
showNotification("Товар удален из чека."); |
|
|
} else { |
|
|
alert(result.error || "Ошибка при удалении товара."); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при удалении товара."); |
|
|
} |
|
|
} |
|
|
|
|
|
async function updateSaleItemQuantity(inputElement) { |
|
|
const tempId = inputElement.dataset.tempId; |
|
|
const newQuantity = parseInt(inputElement.value); |
|
|
const originalQuantity = inputElement.defaultValue ? parseInt(inputElement.defaultValue) : parseInt(inputElement.getAttribute('value')); // Get initial value |
|
|
|
|
|
if (isNaN(newQuantity) || newQuantity < 1) { |
|
|
alert("Количество должно быть не менее 1."); |
|
|
inputElement.value = originalQuantity; |
|
|
return; |
|
|
} |
|
|
|
|
|
// Optimistically update the input value attribute so subsequent changes work correctly |
|
|
inputElement.setAttribute('value', newQuantity); |
|
|
|
|
|
|
|
|
try { |
|
|
const response = await fetch('/sale/update_item_quantity', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
session_id: currentSaleSessionId, |
|
|
temp_id: tempId, |
|
|
new_quantity: newQuantity |
|
|
}) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
// Update the actual rendered list if needed, but the input itself is already updated visually |
|
|
// renderCurrentSaleItems(result.current_sale_cart); // Only necessary if server calculates total prices differently |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); // Refresh product grid |
|
|
showNotification("Количество обновлено."); |
|
|
|
|
|
// Update the displayed total for the item if needed (can be done client-side) |
|
|
const itemRow = inputElement.closest('.sale-item-row'); |
|
|
if(itemRow){ |
|
|
const item = result.current_sale_cart.find(item => item.temp_id === tempId); |
|
|
if(item){ |
|
|
itemRow.querySelector('.sale-item-total').textContent = `${(item.price * item.quantity).toFixed(2)} ${currencyCode}`; |
|
|
} |
|
|
} |
|
|
// Update the overall total (must fetch or calculate) |
|
|
updateCurrentSaleTotalDisplay(result.current_sale_cart); |
|
|
|
|
|
} else { |
|
|
alert(result.error || "Ошибка при обновлении количества."); |
|
|
inputElement.value = originalQuantity; // Revert input value on error |
|
|
inputElement.setAttribute('value', originalQuantity); // Revert attribute too |
|
|
if (result.max_quantity !== undefined) { |
|
|
// Optionally update max based on server response if stock was the issue |
|
|
inputElement.max = result.max_quantity; |
|
|
alert(`Недостаточно товара. Макс. доступно: ${result.max_quantity}.`); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при обновлении количества."); |
|
|
inputElement.value = originalQuantity; // Revert input value on error |
|
|
inputElement.setAttribute('value', originalQuantity); // Revert attribute too |
|
|
} |
|
|
} |
|
|
|
|
|
function updateCurrentSaleTotalDisplay(cartItems) { |
|
|
const currentSaleTotalElement = document.getElementById('current-sale-total'); |
|
|
let total = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0); |
|
|
currentSaleTotalElement.textContent = total.toFixed(2); |
|
|
} |
|
|
|
|
|
|
|
|
function enforceMinMax(inputElement, maxStock) { |
|
|
let value = parseInt(inputElement.value); |
|
|
const min = parseInt(inputElement.min); |
|
|
|
|
|
if (isNaN(value) || value < min) { |
|
|
inputElement.value = min; |
|
|
} else if (value > maxStock) { |
|
|
inputElement.value = maxStock; |
|
|
alert(`Максимальное количество для этого товара: ${maxStock} шт.`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function createNewSaleSession() { |
|
|
try { |
|
|
const response = await fetch('/sale/new_session', { method: 'POST' }); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
await loadSaleSession(result.session_id); // Load the new session |
|
|
showNotification(`Новая касса (ID: ${result.session_id.substring(0, 8)}) создана.`); |
|
|
} else { |
|
|
alert(result.error || "Не удалось создать новую кассу."); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при создании новой кассы."); |
|
|
} |
|
|
} |
|
|
|
|
|
let openSaleSessionsData = {}; // Cache session data locally |
|
|
|
|
|
async function loadSaleSession(sessionId) { |
|
|
try { |
|
|
const response = await fetch(`/sale/load_session/${sessionId}`); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
currentSaleSessionId = sessionId; |
|
|
document.getElementById('current-sale-session-name').textContent = `Касса ${sessionId.substring(0, 8)}`; |
|
|
customerNameInput.value = result.customer_name || ''; |
|
|
customerContactInput.value = result.customer_contact || ''; |
|
|
renderCurrentSaleItems(result.current_sale_cart); |
|
|
openSaleSessionsData = result.active_sessions_data; // Cache loaded sessions data |
|
|
renderSaleSessionsList(Object.values(openSaleSessionsData)); // Render list from cached data |
|
|
updateSaleButtons(); |
|
|
// Reset product selection area |
|
|
selectedProductForSale = null; |
|
|
selectedSaleProductInfoDiv.style.display = 'none'; |
|
|
saleQuantityInput.disabled = true; |
|
|
saleColorSelect.disabled = true; |
|
|
addToSaleButton.disabled = true; |
|
|
saleProductSearchInput.value = ''; |
|
|
filterProductsForSaleGrid(); // Refresh product grid |
|
|
} else { |
|
|
alert(result.error || "Не удалось загрузить кассу."); |
|
|
// If loading failed for the current ID, clear it |
|
|
if (currentSaleSessionId === sessionId) { |
|
|
currentSaleSessionId = null; |
|
|
document.getElementById('current-sale-session-name').textContent = 'Нет активной кассы'; |
|
|
renderCurrentSaleItems([]); |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при загрузке кассы."); |
|
|
if (currentSaleSessionId === sessionId) { |
|
|
currentSaleSessionId = null; |
|
|
document.getElementById('current-sale-session-name').textContent = 'Нет активной кассы'; |
|
|
renderCurrentSaleItems([]); |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function renderSaleSessionsList(sessions) { |
|
|
const listContainer = document.getElementById('sale-sessions-list'); |
|
|
listContainer.innerHTML = ''; |
|
|
if (sessions.length === 0) { |
|
|
listContainer.innerHTML = '<p style="text-align: center; color: #607D8B;">Активных касс нет.</p>'; |
|
|
// If no sessions, ensure no session is selected |
|
|
if (currentSaleSessionId && !sessions.find(s => s.id === currentSaleSessionId)) { |
|
|
currentSaleSessionId = null; |
|
|
document.getElementById('current-sale-session-name').textContent = 'Нет активной кассы'; |
|
|
renderCurrentSaleItems([]); |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); |
|
|
} |
|
|
return; |
|
|
} |
|
|
sessions.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // Sort by latest first |
|
|
sessions.forEach(session => { |
|
|
const sessionDiv = document.createElement('div'); |
|
|
sessionDiv.className = `sale-session-item ${session.id === currentSaleSessionId ? 'active' : ''}`; |
|
|
const totalItems = session.cart_items.reduce((sum, item) => sum + item.quantity, 0); |
|
|
sessionDiv.innerHTML = ` |
|
|
<span>Касса ${session.id.substring(0, 8)} (${totalItems} т.)</span> |
|
|
<button class="delete-button" onclick="deleteSaleSession('${session.id}', event)"> |
|
|
<i class="fas fa-times"></i> |
|
|
</button> |
|
|
`; |
|
|
sessionDiv.onclick = () => loadSaleSession(session.id); |
|
|
listContainer.appendChild(sessionDiv); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function deleteSaleSession(sessionId, event) { |
|
|
event.stopPropagation(); // Prevent loading the session when clicking delete |
|
|
if (!confirm(`Вы уверены, что хотите удалить кассу ${sessionId.substring(0, 8)}? Все товары в ней будут потеряны.`)) { |
|
|
return; |
|
|
} |
|
|
try { |
|
|
const response = await fetch('/sale/delete_session', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ session_id: sessionId }) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
openSaleSessionsData = result.active_sessions_data; // Update local session data |
|
|
if (sessionId === currentSaleSessionId) { |
|
|
currentSaleSessionId = null; |
|
|
document.getElementById('current-sale-session-name').textContent = 'Нет активной кассы'; |
|
|
renderCurrentSaleItems([]); // Clear current items display |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
showNotification(`Касса ${sessionId.substring(0, 8)} удалена.`); |
|
|
} |
|
|
renderSaleSessionsList(Object.values(openSaleSessionsData)); // Render updated list |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); // Refresh product grid |
|
|
} else { |
|
|
alert(result.error || "Не удалось удалить кассу."); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при удалении кассы."); |
|
|
} |
|
|
} |
|
|
|
|
|
async function clearCurrentSaleSession() { |
|
|
if (!currentSaleSessionId) return; |
|
|
if (!confirm(`Вы уверены, что хотите очистить текущий чек Кассы ${currentSaleSessionId.substring(0, 8)}?`)) { |
|
|
return; |
|
|
} |
|
|
try { |
|
|
const response = await fetch('/sale/clear_session', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ session_id: currentSaleSessionId }) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
openSaleSessionsData = result.active_sessions_data; // Update local session data |
|
|
renderCurrentSaleItems(result.current_sale_cart); |
|
|
renderSaleSessionsList(Object.values(openSaleSessionsData)); // Re-render sessions list (total might change) |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); // Refresh product grid (stock availability might change) |
|
|
showNotification(`Текущий чек Кассы ${currentSaleSessionId.substring(0, 8)} очищен.`); |
|
|
} else { |
|
|
alert(result.error || "Не удалось очистить кассу."); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при очистке кассы."); |
|
|
} |
|
|
} |
|
|
|
|
|
async function finalizeCurrentSale() { |
|
|
if (!currentSaleSessionId) { |
|
|
alert("Нет активной кассы для регистрации продажи."); |
|
|
return; |
|
|
} |
|
|
const currentSaleData = openSaleSessionsData[currentSaleSessionId]; |
|
|
if (!currentSaleData || currentSaleData.cart_items.length === 0) { |
|
|
alert("Чек пуст. Невозможно зарегистрировать продажу."); |
|
|
return; |
|
|
} |
|
|
|
|
|
const customerName = customerNameInput.value.trim(); |
|
|
const customerContact = customerContactInput.value.trim(); |
|
|
|
|
|
const confirmButton = document.getElementById('confirm-sale-button'); |
|
|
if (confirmButton) confirmButton.disabled = true; |
|
|
|
|
|
showNotification("Регистрация продажи...", 5000); |
|
|
|
|
|
try { |
|
|
const response = await fetch('/sale/finalize_sale', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
session_id: currentSaleSessionId, |
|
|
customer_name: customerName, |
|
|
customer_contact: customerContact |
|
|
}) |
|
|
}); |
|
|
const result = await response.json(); |
|
|
if (response.ok) { |
|
|
alert(`Продажа №${result.order_id} успешно зарегистрирована!`); |
|
|
|
|
|
// Clean up the session locally |
|
|
delete openSaleSessionsData[currentSaleSessionId]; |
|
|
currentSaleSessionId = null; |
|
|
document.getElementById('current-sale-session-name').textContent = 'Нет активной кассы'; |
|
|
customerNameInput.value = ''; |
|
|
customerContactInput.value = ''; |
|
|
renderCurrentSaleItems([]); // Clear current items display |
|
|
renderSaleSessionsList(Object.values(openSaleSessionsData)); // Re-render sessions list |
|
|
|
|
|
updateSaleButtons(); |
|
|
filterProductsForSaleGrid(); // Refresh product grid (stock updated) |
|
|
|
|
|
} else { |
|
|
alert(result.error || "Не удалось зарегистрировать продажу."); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Ошибка:', error); |
|
|
alert("Произошла ошибка при регистрации продажи."); |
|
|
} finally { |
|
|
if (confirmButton) confirmButton.disabled = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateSaleButtons() { |
|
|
// Check if there is an active session and if it has items |
|
|
const currentSaleData = currentSaleSessionId && openSaleSessionsData[currentSaleSessionId] |
|
|
? openSaleSessionsData[currentSaleSessionId] |
|
|
: null; |
|
|
|
|
|
const hasItems = currentSaleData && currentSaleData.cart_items.length > 0; |
|
|
|
|
|
confirmSaleButton.disabled = !hasItems; |
|
|
clearCurrentSaleButton.disabled = !hasItems; |
|
|
|
|
|
// Add/Color/Quantity buttons depend on selectedProductForSale AND active session |
|
|
const isProductSelected = selectedProductForSale !== null; |
|
|
addToSaleButton.disabled = !isProductSelected || !currentSaleSessionId; |
|
|
saleQuantityInput.disabled = !isProductSelected || !currentSaleSessionId; |
|
|
saleColorSelect.disabled = !isProductSelected || !currentSaleSessionId; |
|
|
|
|
|
} |
|
|
|
|
|
function showNotification(message, duration = 3000) { |
|
|
let placeholder = document.getElementById('notification-placeholder'); |
|
|
// Use the Admin template's notification logic or duplicate if needed |
|
|
// For now, duplicating for simplicity in SALE_TEMPLATE |
|
|
if (!placeholder) { |
|
|
const newPlaceholder = document.createElement('div'); |
|
|
newPlaceholder.id = 'notification-placeholder'; |
|
|
newPlaceholder.style.position = 'fixed'; |
|
|
newPlaceholder.style.bottom = '25px'; /* Adjust position if needed for Sale page */ |
|
|
newPlaceholder.style.left = '50%'; |
|
|
newPlaceholder.style.transform = 'translateX(-50%)'; |
|
|
newPlaceholder.style.zIndex = '1002'; |
|
|
document.body.appendChild(newPlaceholder); |
|
|
placeholder = newPlaceholder; |
|
|
} |
|
|
|
|
|
// Remove any existing notifications before adding a new one |
|
|
placeholder.innerHTML = ''; |
|
|
|
|
|
const notification = document.createElement('div'); |
|
|
notification.className = 'notification'; // Use existing .notification style |
|
|
notification.textContent = message; |
|
|
placeholder.appendChild(notification); |
|
|
|
|
|
// Trigger reflow to ensure transition runs |
|
|
void notification.offsetWidth; |
|
|
|
|
|
notification.classList.add('show'); |
|
|
|
|
|
setTimeout(() => { |
|
|
notification.classList.remove('show'); |
|
|
notification.addEventListener('transitionend', () => notification.remove()); |
|
|
}, duration); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
saleProductSearchInput.addEventListener('input', filterProductsForSaleGrid); |
|
|
renderAllProductsForSale(allProducts); |
|
|
|
|
|
// Initialize scanner modal close listener |
|
|
const scannerModal = document.getElementById('scannerModal'); |
|
|
if (scannerModal) { |
|
|
scannerModal.addEventListener('click', function(event) { |
|
|
if (event.target === scannerModal) { |
|
|
closeScannerModal(); |
|
|
} |
|
|
}); |
|
|
// Add keydown listener for Escape specifically for the scanner modal |
|
|
window.addEventListener('keydown', function(event) { |
|
|
if (event.key === 'Escape' && scannerModal && scannerModal.style.display === 'flex') { |
|
|
closeScannerModal(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
// Load active sessions on page load |
|
|
fetch('/sale/active_sessions') |
|
|
.then(response => response.json()) |
|
|
.then(data => { |
|
|
openSaleSessionsData = data.active_sessions_data; // Cache received data structure |
|
|
renderSaleSessionsList(Object.values(openSaleSessionsData)); |
|
|
// Attempt to load the first session if any exist |
|
|
const sessionIds = Object.keys(openSaleSessionsData); |
|
|
if (sessionIds.length > 0) { |
|
|
// Find the latest session by created_at timestamp to load |
|
|
const latestSessionId = sessionIds.sort((a, b) => |
|
|
new Date(openSaleSessionsData[b].created_at) - new Date(openSaleSessionsData[a].created_at) |
|
|
)[0]; |
|
|
loadSaleSession(latestSessionId); |
|
|
} else { |
|
|
updateSaleButtons(); // Ensure buttons are disabled if no sessions |
|
|
} |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Ошибка загрузки активных сессий:', error); |
|
|
alert("Не удалось загрузить список активных касс."); |
|
|
updateSaleButtons(); // Ensure buttons are disabled on error |
|
|
}); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def catalog(): |
|
|
data = load_data() |
|
|
all_products = data.get('products', []) |
|
|
categories = sorted(data.get('categories', [])) |
|
|
|
|
|
|
|
|
products_in_stock = [p for p in all_products if p.get('stock', 0) > 0] |
|
|
|
|
|
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/<product_id>') |
|
|
def product_detail(product_id): |
|
|
data = load_data() |
|
|
all_products = data.get('products', []) |
|
|
|
|
|
|
|
|
product = next((p for p in all_products if p.get('id') == product_id and p.get('stock', 0) > 0), None) |
|
|
|
|
|
if not product: |
|
|
|
|
|
return render_template_string( |
|
|
''' |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Товар не найден</title> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
body { font-family: 'Poppins', sans-serif; background: #F5F5F5; color: #263238; line-height: 1.6; padding: 40px; text-align: center;} |
|
|
.container { max-width: 600px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); border: 1px solid #CFD8DC; } |
|
|
h1 { color: #c53030; margin-bottom: 20px; } |
|
|
p { font-size: 1.1rem; color: #607D8B; } |
|
|
a { display: inline-block; margin-top: 20px; color: #303F9F; text-decoration: none; font-weight: 500;} |
|
|
a:hover { text-decoration: underline;} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>Товар не найден</h1> |
|
|
<p>Возможно, товар был удален, распродан или временно недоступен.</p> |
|
|
<a href="{{ url_for('catalog') }}">Вернуться в каталог</a> |
|
|
</div> |
|
|
</body> |
|
|
</html> |
|
|
''' |
|
|
, url_for=url_for), 404 |
|
|
|
|
|
|
|
|
if not product.get('photos') or len(product['photos']) == 0: |
|
|
product['photos'] = ['placeholder.png'] |
|
|
|
|
|
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 = {} |
|
|
|
|
|
for item_raw in cart_items_raw: |
|
|
required_keys = ['product_id', 'name', 'price', 'quantity', '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') |
|
|
|
|
|
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}.") |
|
|
|
|
|
products_to_update_stock.pop(product_id, None) |
|
|
return jsonify({"error": f"Недостаточно товара '{item_raw['name']}' на складе. Доступно: {current_stock} шт."}), 400 |
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
"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", |
|
|
"status": "new", |
|
|
"customer_name": None, |
|
|
"customer_contact": None |
|
|
} |
|
|
|
|
|
try: |
|
|
|
|
|
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 |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
current_data_after_failure = load_data() |
|
|
|
|
|
original_products_map = {p['id']: p for p in load_data().get('products', [])} |
|
|
for prod_in_list in data['products']: |
|
|
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 |
|
|
|
|
|
data['orders'].pop(order_id, None) |
|
|
|
|
|
|
|
|
|
|
|
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/<order_id>') |
|
|
def view_order(order_id): |
|
|
"""Displays a specific order details page.""" |
|
|
data = load_data() |
|
|
order = data.get('orders', {}).get(order_id) |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
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 |
|
|
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) |
|
|
|
|
|
|
|
|
if barcode: |
|
|
if any(p.get('barcode') == barcode for p in products): |
|
|
flash(f"Штрихкод '{barcode}' уже присвоен другому товару.", '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: |
|
|
pass |
|
|
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}") |
|
|
else: |
|
|
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning") |
|
|
|
|
|
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 |
|
|
} |
|
|
products.append(new_product) |
|
|
data['products'] = products |
|
|
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', '') |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
try: |
|
|
stock = int(stock_str) |
|
|
if stock < 0: stock = 0 |
|
|
product_to_edit['stock'] = stock |
|
|
product_to_edit['in_stock'] = stock > 0 |
|
|
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') |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
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', []) |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
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 |
|
|
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'] |
|
|
if new_photos_list: |
|
|
flash("Фотографии товара успешно обновлены.", "success") |
|
|
else: |
|
|
flash("Новые фотографии не были выбраны или загружены. Старые фото удалены.", "warning") |
|
|
|
|
|
else: |
|
|
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning") |
|
|
|
|
|
product_to_edit['photos'] = product_to_edit.get('photos', ['placeholder.png']) |
|
|
|
|
|
|
|
|
data['products'] = products |
|
|
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 |
|
|
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 |
|
|
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) |
|
|
|
|
|
|
|
|
current_data = load_data() |
|
|
|
|
|
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower()) |
|
|
|
|
|
display_categories = sorted(current_data.get('categories', [])) |
|
|
orders = current_data.get('orders', {}) |
|
|
|
|
|
display_orders = sorted(orders.values(), key=lambda o: o.get('created_at', ''), reverse=True) |
|
|
|
|
|
|
|
|
total_orders = len(orders) |
|
|
total_revenue = 0 |
|
|
product_sales_count = Counter() |
|
|
|
|
|
|
|
|
for order_id, order_data in orders.items(): |
|
|
|
|
|
status = order_data.get('status') |
|
|
if status in ['completed', 'new']: |
|
|
total_revenue += order_data.get('total_price', 0) |
|
|
for item in order_data.get('cart', []): |
|
|
|
|
|
product_sales_count[item.get('name', 'Unknown Product')] += item.get('quantity', 0) |
|
|
|
|
|
top_selling_products = product_sales_count.most_common(5) |
|
|
|
|
|
|
|
|
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_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(): |
|
|
|
|
|
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() |
|
|
|
|
|
all_products = sorted(data.get('products', []), key=lambda p: p.get('name', '').lower()) |
|
|
|
|
|
|
|
|
for product in all_products: |
|
|
if not product.get('photos'): |
|
|
product['photos'] = ['placeholder.png'] |
|
|
|
|
|
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}") |
|
|
|
|
|
return jsonify({"session_id": session_id, "active_sessions": list(open_sale_sessions.keys())}) |
|
|
|
|
|
@app.route('/sale/load_session/<session_id>') |
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
for item in sale_session['cart_items']: |
|
|
product_data = products_map.get(item['product_id']) |
|
|
|
|
|
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) |
|
|
}) |
|
|
|
|
|
|
|
|
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'] |
|
|
} |
|
|
|
|
|
|
|
|
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 |
|
|
}) |
|
|
|
|
|
@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'] |
|
|
} |
|
|
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] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 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 |
|
|
|
|
|
elif not (s_id == session_id and existing_item_in_session_index != -1): |
|
|
total_reserved_across_sessions += item_in_session['quantity'] |
|
|
elif s_id != session_id: |
|
|
total_reserved_across_sessions += item_in_session['quantity'] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
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) |
|
|
}), 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, |
|
|
"product_id": product_id, |
|
|
"name": product_in_db['name'], |
|
|
"price": product_in_db['price'], |
|
|
"quantity": quantity, |
|
|
"color": color, |
|
|
"photo": selected_photo |
|
|
}) |
|
|
logging.info(f"Added new item {new_item_temp_id} to session {session_id}") |
|
|
|
|
|
|
|
|
current_sale_cart_formatted = [] |
|
|
total_price = 0 |
|
|
|
|
|
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 |
|
|
|
|
|
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'], |
|
|
"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) |
|
|
}) |
|
|
|
|
|
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'] |
|
|
} |
|
|
|
|
|
|
|
|
return jsonify({ |
|
|
"current_sale_cart": current_sale_cart_formatted, |
|
|
"total_price": round(total_price, 2), |
|
|
"active_sessions_data": active_sessions_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}") |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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'], |
|
|
"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) |
|
|
}) |
|
|
|
|
|
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'] |
|
|
} |
|
|
|
|
|
|
|
|
return jsonify({ |
|
|
"current_sale_cart": current_sale_cart_formatted, |
|
|
"total_price": round(total_price, 2), |
|
|
"active_sessions_data": active_sessions_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'] |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
total_reserved_excluding_this_specific_item_old_qty += item_in_session['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) |
|
|
}), 400 |
|
|
|
|
|
|
|
|
sale_session['cart_items'][item_index]['quantity'] = new_quantity |
|
|
logging.info(f"Updated item {temp_id} quantity in session {session_id} to {new_quantity}") |
|
|
|
|
|
|
|
|
current_sale_cart_formatted = [] |
|
|
total_price = 0 |
|
|
|
|
|
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 |
|
|
|
|
|
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'], |
|
|
"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) |
|
|
}) |
|
|
|
|
|
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'] |
|
|
} |
|
|
|
|
|
|
|
|
return jsonify({ |
|
|
"current_sale_cart": current_sale_cart_formatted, |
|
|
"total_price": round(total_price, 2), |
|
|
"active_sessions_data": active_sessions_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'] = '' |
|
|
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'] |
|
|
} |
|
|
|
|
|
return jsonify({ |
|
|
"current_sale_cart": [], |
|
|
"total_price": 0.0, |
|
|
"active_sessions_data": active_sessions_data |
|
|
}) |
|
|
|
|
|
@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'] |
|
|
} |
|
|
|
|
|
return jsonify({"active_sessions_data": active_sessions_data}) |
|
|
|
|
|
@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 = {} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
product_color_reserved_qty = Counter() |
|
|
for s_id, s_data in open_sale_sessions.items(): |
|
|
for item_in_session in s_data['cart_items']: |
|
|
|
|
|
key = (item_in_session['product_id'], item_in_session['color']) |
|
|
product_color_reserved_qty[key] += item_in_session['quantity'] |
|
|
|
|
|
|
|
|
for item in sale_session['cart_items']: |
|
|
product_id = item['product_id'] |
|
|
quantity = int(item['quantity']) |
|
|
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)] |
|
|
|
|
|
|
|
|
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.") |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
products_to_update_stock[product_id] = stock_after_this_item_sale |
|
|
|
|
|
|
|
|
total_price += item['price'] * quantity |
|
|
|
|
|
|
|
|
photo_filename = item['photo'] |
|
|
|
|
|
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" |
|
|
}) |
|
|
|
|
|
|
|
|
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", |
|
|
"customer_name": customer_name if customer_name else "Анонимный покупатель", |
|
|
"customer_contact": customer_contact if customer_contact else "Не указан" |
|
|
} |
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
del open_sale_sessions[session_id] |
|
|
|
|
|
logging.info(f"Sale {sale_id} registered successfully and 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'] |
|
|
} |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
return jsonify({"error": "Ошибка сервера при регистрации продажи. Пожалуйста, попробуйте позже."}), 500 |
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
|
|
logging.info("Attempting initial database download from Hugging Face...") |
|
|
download_db_from_hf() |
|
|
logging.info("Initial download attempt finished. Loading data...") |
|
|
|
|
|
load_data() |
|
|
logging.info("Data loaded. Starting Flask application.") |
|
|
|
|
|
|
|
|
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.") |
|
|
|
|
|
|
|
|
port = int(os.environ.get('PORT', 7860)) |
|
|
app.run(debug=False, host='0.0.0.0', port=port) |
|
|
|