makscap / app.py
Kgshop's picture
Update app.py
1cea951 verified
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, abort, Response, session, g
import json
import os
import logging
import threading
import time
from datetime import datetime, timedelta
import pytz
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
import uuid
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
import functools
from functools import wraps
from collections import defaultdict
from urllib.parse import quote
app = Flask(__name__)
app.secret_key = os.urandom(24)
app.permanent_session_lifetime = timedelta(days=7)
ADMIN_PASS = os.getenv("ADMIN_PASS", "admin123")
DATA_DIR = 'pos_data'
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(os.path.join(app.static_folder, 'product_images'), exist_ok=True)
INVENTORY_FILE = os.path.join(DATA_DIR, 'inventory.json')
TRANSACTIONS_FILE = os.path.join(DATA_DIR, 'transactions.json')
USERS_FILE = os.path.join(DATA_DIR, 'users.json')
KASSAS_FILE = os.path.join(DATA_DIR, 'kassas.json')
EXPENSES_FILE = os.path.join(DATA_DIR, 'expenses.json')
PERSONAL_EXPENSES_FILE = os.path.join(DATA_DIR, 'personal_expenses.json')
SHIFTS_FILE = os.path.join(DATA_DIR, 'shifts.json')
SETTINGS_FILE = os.path.join(DATA_DIR, 'settings.json')
ORDERS_FILE = os.path.join(DATA_DIR, 'orders.json')
DEBTS_FILE = os.path.join(DATA_DIR, 'debts.json')
DATA_FILES = {
'inventory': (INVENTORY_FILE, threading.Lock()),
'transactions': (TRANSACTIONS_FILE, threading.Lock()),
'users': (USERS_FILE, threading.Lock()),
'kassas': (KASSAS_FILE, threading.Lock()),
'expenses': (EXPENSES_FILE, threading.Lock()),
'personal_expenses': (PERSONAL_EXPENSES_FILE, threading.Lock()),
'shifts': (SHIFTS_FILE, threading.Lock()),
'settings': (SETTINGS_FILE, threading.Lock()),
'orders': (ORDERS_FILE, threading.Lock()),
'debts': (DEBTS_FILE, threading.Lock()),
}
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE")
REPO_ID = "Kgshop/makscap"
ALMATY_TZ = pytz.timezone('Asia/Almaty')
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def get_current_time():
return datetime.now(ALMATY_TZ)
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return str(obj)
return json.JSONEncoder.default(self, obj)
def to_decimal(value_str, default='0.00'):
if value_str is None or value_str == '':
return Decimal(default)
try:
return Decimal(str(value_str).replace(',', '.'))
except InvalidOperation:
logging.warning(f"Could not convert '{value_str}' to Decimal. Returned {default}.")
return Decimal(default)
def sync_images_from_hf(force=False):
logging.info("Starting image sync from Hugging Face...")
try:
snapshot_download(
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=app.static_folder,
allow_patterns=["product_images/*"],
local_dir_use_symlinks=False,
force_download=force,
)
logging.info("Image sync completed successfully.")
except HfHubHTTPError as e:
if e.response.status_code == 404:
logging.warning("product_images folder not found in the HF repo. Skipping image sync.")
else:
logging.error(f"HTTP error during image sync: {e}")
except Exception as e:
logging.error(f"An unexpected error occurred during image sync: {e}")
def load_json_data(file_key, default_value=None):
filepath, lock = DATA_FILES[file_key]
filename = os.path.basename(filepath)
with lock:
try:
hf_hub_download(
repo_id=REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=DATA_DIR, local_dir_use_symlinks=False
)
except HfHubHTTPError as e:
if e.response.status_code != 404:
logging.error(f"HTTP error downloading {filename}: {e}")
except Exception as e:
logging.error(f"Unknown error downloading {filename}: {e}")
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
except (FileNotFoundError, json.JSONDecodeError):
return default_value if default_value is not None else []
def save_json_data(file_key, data):
filepath, lock = DATA_FILES[file_key]
with lock:
try:
temp_file = filepath + ".tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4, cls=DecimalEncoder)
os.replace(temp_file, filepath)
except Exception as e:
logging.error(f"Critical error saving {filepath}: {e}", exc_info=True)
@functools.lru_cache(maxsize=1)
def get_hf_api():
if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE":
return None
try:
return HfApi()
except Exception as e:
logging.error(f"Error initializing HfApi: {e}")
return None
def upload_db_to_hf(file_key):
api = get_hf_api()
if not api:
return
filepath, _ = DATA_FILES[file_key]
if not os.path.exists(filepath):
return
try:
filename = os.path.basename(filepath)
commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z')
api.upload_file(
path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset",
token=HF_TOKEN_WRITE, commit_message=f"Automated backup {filename} {commit_time}",
run_as_future=True
)
except Exception as e:
logging.error(f"Error initiating upload of {filepath}: {e}")
def periodic_backup():
while True:
time.sleep(1800)
try:
for key in DATA_FILES.keys():
upload_db_to_hf(key)
except Exception as e:
logging.error(f"Error during scheduled backup: {e}", exc_info=True)
def find_item_by_field(data, field, value):
for item in data:
if isinstance(item, dict) and str(item.get(field)) == str(value):
return item
return None
def find_user_by_pin(pin):
users = load_json_data('users')
return find_item_by_field(users, 'pin', pin)
def format_currency_py(value):
try:
number = to_decimal(value)
return f"{number:,.2f}".replace(",", " ").replace(".", ",")
except (InvalidOperation, TypeError, ValueError):
return "0,00"
def generate_receipt_html(transaction):
payment_method_map = {
'cash': 'Наличные',
'card': 'Карта',
'debt': 'В долг'
}
payment_method_display = payment_method_map.get(transaction['payment_method'], transaction['payment_method'])
if transaction['payment_method'] == 'debt':
payment_method_display += f" ({transaction.get('debt_info', {}).get('name', 'N/A')})"
table_headers = """
<th style="text-align: center; width: 5%;">№</th>
<th style="text-align: left;">Наименование</th>
<th style="text-align: right;">Кол-во</th>
<th style="text-align: right;">Цена</th>
<th style="text-align: right;">Сумма</th>
"""
total_colspan = 4
items_html = ""
for i, item in enumerate(transaction['items']):
items_html += f"""
<tr>
<td style="text-align: center;">{i + 1}</td>
<td>{item['name']}</td>
<td style="text-align: right;">{item['quantity']}</td>
<td style="text-align: right;">{format_currency_py(item['price_at_sale'])}</td>
<td style="text-align: right;">{format_currency_py(item['total'])}</td>
</tr>
"""
subtotal = sum(to_decimal(item['total']) for item in transaction.get('items', []))
total_discount = to_decimal(transaction.get('total_discount', '0'))
loader_fee = to_decimal(transaction.get('loader_fee', '0'))
commission_fee = to_decimal(transaction.get('commission_fee', '0'))
summary_html = ""
if total_discount > 0 or loader_fee > 0 or commission_fee > 0:
summary_html += f"""
<tr class="summary">
<td colspan="{total_colspan}" style="text-align: right;">Сумма по товарам:</td>
<td style="text-align: right;">{format_currency_py(subtotal)} ₸</td>
</tr>
"""
if total_discount > 0:
summary_html += f"""
<tr class="summary">
<td colspan="{total_colspan}" style="text-align: right;">Скидка:</td>
<td style="text-align: right; color: red;">-{format_currency_py(total_discount)} ₸</td>
</tr>
"""
if loader_fee > 0:
summary_html += f"""
<tr class="summary">
<td colspan="{total_colspan}" style="text-align: right;">Услуги грузчика:</td>
<td style="text-align: right;">{format_currency_py(loader_fee)} ₸</td>
</tr>
"""
if commission_fee > 0:
summary_html += f"""
<tr class="summary">
<td colspan="{total_colspan}" style="text-align: right;">Комиссия за перевод ({transaction.get('commission_rate', '0.95')}%):</td>
<td style="text-align: right;">{format_currency_py(commission_fee)} ₸</td>
</tr>
"""
total_amount_str = format_currency_py(transaction['total_amount'])
return f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Накладная {transaction['id'][:8]}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 10px; background-color: #f4f4f4; color: #333; }}
.invoice-box {{ max-width: 800px; margin: auto; padding: 20px; border: 1px solid #eee; background: white; box-shadow: 0 0 10px rgba(0,0,0,0.15); }}
.header {{ text-align: center; margin-bottom: 20px; }}
.header h1 {{ margin: 0; font-size: 22px; font-weight: 600; }}
.header p {{ margin: 2px 0; font-size: 14px; }}
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; }}
table td {{ padding: 8px; border-bottom: 1px solid #eee; }}
table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
.footer-info {{ font-size: 14px; margin-top: 20px; }}
.print-hide {{ display: block; }}
@media print {{
body {{ margin: 0; padding: 0; background-color: #fff; }}
.invoice-box {{ box-shadow: none; border: none; margin: 0; padding: 0; }}
.print-hide {{ display: none; }}
}}
@media screen and (max-width: 600px) {{
body {{ padding: 0; }}
.invoice-box {{ padding: 15px; box-shadow: none; border: none; }}
table th, table td {{ font-size: 12px; padding: 5px; }}
.header h1 {{ font-size: 18px; }}
.header p {{ font-size: 12px; }}
table tr.total td {{ font-size: 1em; }}
}}
</style>
</head>
<body>
<div class="invoice-box">
<div class="print-hide" style="text-align: right; margin-bottom: 20px;">
<button onclick="window.print()" style="padding: 8px 12px; font-size: 14px; cursor: pointer;">Печать</button>
</div>
<div class="header">
<h1>Товарная накладная № {transaction['id'][:8]}</h1>
<p>от {datetime.fromisoformat(transaction['timestamp']).strftime('%d.%m.%Y %H:%M')}</p>
</div>
<table>
<thead>
<tr>{table_headers}</tr>
</thead>
<tbody>{items_html}</tbody>
</table>
<table style="margin-top: 20px;">
{summary_html}
<tr class="total">
<td colspan="{total_colspan}" style="text-align: right;">Итого к оплате:</td>
<td style="text-align: right;">{total_amount_str} ₸</td>
</tr>
</table>
<div class="footer-info">
<p>Способ оплаты: {payment_method_display}</p>
<p>Кассир: {transaction['user_name']}</p>
</div>
</div>
</body>
</html>
"""
def generate_order_html(order_data, settings):
items_html = ""
total_quantity = 0
total_amount = Decimal('0.00')
whatsapp_number = settings.get('whatsapp_number', '').strip().replace('+', '').replace(' ', '')
for product_id, product_data in order_data.get('items', {}).items():
price = to_decimal(product_data.get('price', '0'))
items_by_color_html = ""
product_total_quantity_for_product = 0
for item in product_data['items']:
item_quantity = item.get('quantity', 0)
product_total_quantity_for_product += item_quantity
item_subtotal = price * Decimal(item_quantity)
items_by_color_html += f"""
<div style="margin-left: 20px; padding: 5px 0; border-bottom: 1px dotted #ccc; display: flex; justify-content: space-between;">
<span>Цвет: <strong>{item['color']}</strong>, Количество: <strong>{item_quantity} шт.</strong></span>
<span style="white-space: nowrap;">{format_currency_py(item_subtotal)} ₸</span>
</div>
"""
product_total_amount = price * Decimal(product_total_quantity_for_product)
total_quantity += product_total_quantity_for_product
total_amount += product_total_amount
items_html += f"""
<tr>
<td style="text-align: center; vertical-align: middle;">
<img src="{product_data.get('image', '')}" alt="{product_data.get('name', 'image')}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px;">
</td>
<td>
<p style="font-weight: bold; margin: 0 0 5px 0;">{product_data.get('name', 'N/A')}</p>
<p style="font-size: 0.9em; color: #555; margin: 0 0 5px 0;">Цена за шт.: {format_currency_py(price)} ₸</p>
{items_by_color_html}
</td>
<td style="text-align: right; vertical-align: middle; font-weight: bold;">{format_currency_py(product_total_amount)} ₸</td>
</tr>
"""
return f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Заказ № {order_data['id'][:8]}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 10px; background-color: #f4f4f4; color: #333; }}
.invoice-box {{ max-width: 800px; margin: auto; padding: 20px; border: 1px solid #eee; background: white; box-shadow: 0 0 10px rgba(0,0,0,0.15); }}
.header {{ text-align: center; margin-bottom: 20px; }}
.header h1 {{ margin: 0; font-size: 22px; font-weight: 600; }}
.header p {{ margin: 2px 0; font-size: 14px; }}
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; }}
table td {{ padding: 8px; border-bottom: 1px solid #eee; }}
.totals-table {{ width: auto; margin-left: auto; margin-top: 20px; }}
.totals-table td {{ border: none; padding: 5px 8px; }}
.totals-table tr.grand-total td {{ font-weight: bold; font-size: 1.2em; border-top: 2px solid #333; }}
.send-order-btn {{ display: block; width: 100%; padding: 15px; background-color: #25D366; color: white; text-align: center; text-decoration: none; font-size: 18px; font-weight: bold; border: none; border-radius: 5px; cursor: pointer; margin-top: 20px; }}
@media screen and (max-width: 600px) {{
body {{ padding: 0; }}
.invoice-box {{ padding: 15px; box-shadow: none; border: none; }}
table th, table td {{ font-size: 12px; padding: 5px; }}
.header h1 {{ font-size: 18px; }}
}}
</style>
</head>
<body>
<div class="invoice-box">
<div class="header">
<h1>Заказ № {order_data['id'][:8]}</h1>
<p>от {datetime.fromisoformat(order_data['timestamp']).strftime('%d.%m.%Y %H:%M')}</p>
</div>
<table>
<thead>
<tr>
<th style="text-align: center; width: 100px;">Фото</th>
<th style="text-align: left;">Наименование и детали</th>
<th style="text-align: right; width: 120px;">Сумма</th>
</tr>
</thead>
<tbody>{items_html}</tbody>
</table>
<table class="totals-table">
<tr>
<td style="text-align: right;">Итого позиций:</td>
<td style="text-align: right;">{total_quantity} шт.</td>
</tr>
<tr class="grand-total">
<td style="text-align: right;">Общая сумма:</td>
<td style="text-align: right;">{format_currency_py(total_amount)} ₸</td>
</tr>
</table>
<button class="send-order-btn" onclick="sendOrder()">Отправить заказ в WhatsApp</button>
</div>
<script>
function sendOrder() {{
const phoneNumber = '{whatsapp_number}';
if (!phoneNumber) {{
alert('Номер WhatsApp не настроен в системе.');
return;
}}
const message = `Здравствуйте, новый заказ:\\n${{window.location.href}}`;
const whatsappUrl = `https://wa.me/${{phoneNumber}}?text=${{encodeURIComponent(message)}}`;
window.open(whatsappUrl, '_blank');
}}
</script>
</body>
</html>
"""
def user_can_view_orders(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'admin_logged_in' in session:
return f(*args, **kwargs)
shifts = load_json_data('shifts')
open_shift = next((s for s in shifts if s.get('end_time') is None), None)
if open_shift:
return f(*args, **kwargs)
flash("Для доступа к этой странице требуется активная смена или вход администратора.", "warning")
return redirect(url_for('sales_screen'))
return decorated_function
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'admin_logged_in' not in session:
flash("Для доступа к этой странице требуется аутентификация.", "warning")
return redirect(url_for('admin_login', next=request.url))
return f(*args, **kwargs)
return decorated_function
@app.context_processor
def inject_utils():
new_order_count = 0
if 'admin_logged_in' in session or any(s.get('end_time') is None for s in load_json_data('shifts')):
orders = load_json_data('orders')
new_order_count = sum(1 for o in orders if o.get('status') == 'Новый заказ')
return {
'format_currency_py': format_currency_py,
'get_current_time': get_current_time,
'quote': quote,
'new_order_count': new_order_count
}
@app.route('/')
def sales_screen():
inventory = load_json_data('inventory')
kassas = load_json_data('kassas')
active_inventory = []
for p in inventory:
if isinstance(p, dict) and any(v.get('stock', 0) > 0 for v in p.get('variants', [])):
active_inventory.append(p)
active_inventory.sort(key=lambda x: x.get('name', '').lower())
grouped_inventory = defaultdict(list)
for p in active_inventory:
first_letter = p.get('name', '#')[0].upper()
grouped_inventory[first_letter].append(p)
sorted_grouped_inventory = sorted(grouped_inventory.items())
html = BASE_TEMPLATE.replace('__TITLE__', "Касса").replace('__CONTENT__', SALES_SCREEN_CONTENT).replace('__SCRIPTS__', SALES_SCREEN_SCRIPTS)
return render_template_string(html, inventory=active_inventory, kassas=kassas, grouped_inventory=sorted_grouped_inventory)
@app.route('/shop')
def shop_catalog():
inventory = load_json_data('inventory')
active_inventory = []
for p in inventory:
if isinstance(p, dict) and 'variants' in p:
active_variants = [v for v in p.get('variants', [])]
if active_variants:
product_copy = p.copy()
product_copy['variants'] = active_variants
total_stock = sum(v.get('stock', 0) for v in active_variants)
product_copy['total_stock'] = total_stock
active_inventory.append(product_copy)
active_inventory.sort(key=lambda x: x.get('name', '').lower())
html = SHOP_BASE_TEMPLATE.replace('__TITLE__', "Каталог товаров").replace('__CONTENT__', SHOP_CATALOG_CONTENT).replace('__SCRIPTS__', SHOP_CATALOG_SCRIPTS)
return render_template_string(html, products=active_inventory)
@app.route('/api/product_detail/<product_id>')
def api_product_detail(product_id):
inventory = load_json_data('inventory')
product = find_item_by_field(inventory, 'id', product_id)
if not product:
return jsonify({'success': False, 'message': 'Товар не найден'}), 404
active_variants = [v for v in product.get('variants', [])]
if not active_variants:
return jsonify({'success': False, 'message': 'Варианты товара не найдены'}), 404
product_copy = product.copy()
product_copy['variants'] = active_variants
return jsonify({'success': True, 'product': product_copy})
@app.route('/api/create_order', methods=['POST'])
def create_order():
try:
cart_data = request.get_json()
if not cart_data:
return jsonify({'success': False, 'message': 'Корзина пуста'}), 400
inventory = load_json_data('inventory')
settings = load_json_data('settings', default_value={})
order_items = {}
for item_key, cart_item in cart_data.items():
product_id, color = item_key.split('_')
if product_id not in order_items:
product_info = find_item_by_field(inventory, 'id', product_id)
if product_info:
first_variant_with_images = next((v for v in product_info.get('variants', []) if v.get('image_urls')), None)
image_url = first_variant_with_images['image_urls'][0] if first_variant_with_images and first_variant_with_images['image_urls'] else url_for('static', filename='placeholder.png')
order_items[product_id] = {
'name': product_info.get('name'),
'image': image_url,
'price': cart_item.get('price'),
'items': []
}
if product_id in order_items:
order_items[product_id]['items'].append({
'color': color,
'quantity': cart_item.get('quantity')
})
order_id = uuid.uuid4().hex
new_order = {
'id': order_id,
'timestamp': get_current_time().isoformat(),
'items': order_items,
'status': 'Новый заказ'
}
orders = load_json_data('orders')
orders.append(new_order)
save_json_data('orders', orders)
upload_db_to_hf('orders')
order_url = url_for('view_order', order_id=order_id, _external=True)
return jsonify({'success': True, 'orderUrl': order_url})
except Exception as e:
logging.error(f"Error creating order: {e}", exc_info=True)
return jsonify({'success': False, 'message': f'Внутренняя ошибка сервера: {e}'}), 500
@app.route('/order/<order_id>')
def view_order(order_id):
orders = load_json_data('orders')
settings = load_json_data('settings', default_value={})
order = find_item_by_field(orders, 'id', order_id)
if order:
order_html = generate_order_html(order, settings)
return Response(order_html, mimetype='text/html')
abort(404, description="Заказ не найден")
@app.route('/online_orders')
@user_can_view_orders
def online_orders():
orders = load_json_data('orders')
orders.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Онлайн заказы").replace('__CONTENT__', ONLINE_ORDERS_CONTENT).replace('__SCRIPTS__', ONLINE_ORDERS_SCRIPTS)
return render_template_string(html, orders=orders)
@app.route('/api/order/update_status/<order_id>', methods=['POST'])
@user_can_view_orders
def update_order_status(order_id):
new_status = request.json.get('status')
if not new_status:
return jsonify({'success': False, 'message': 'Статус не указан'}), 400
orders = load_json_data('orders')
order_found = False
for order in orders:
if order.get('id') == order_id:
order['status'] = new_status
order_found = True
break
if order_found:
save_json_data('orders', orders)
upload_db_to_hf('orders')
return jsonify({'success': True, 'message': 'Статус обновлен'})
else:
return jsonify({'success': False, 'message': 'Заказ не найден'}), 404
@app.route('/api/order/edit_items/<order_id>', methods=['POST'])
@admin_required
def edit_order_items(order_id):
updated_items_data = request.json.get('items')
if not updated_items_data:
return jsonify({'success': False, 'message': 'Нет данных для обновления'}), 400
orders = load_json_data('orders')
order = find_item_by_field(orders, 'id', order_id)
if not order:
return jsonify({'success': False, 'message': 'Заказ не найден'}), 404
try:
for product_id, new_items in updated_items_data.items():
if product_id in order['items']:
valid_new_items = []
for item in new_items:
quantity = int(item.get('quantity', 0))
if quantity > 0:
valid_new_items.append({
'color': item.get('color'),
'quantity': quantity
})
if valid_new_items:
order['items'][product_id]['items'] = valid_new_items
else:
del order['items'][product_id]
save_json_data('orders', orders)
upload_db_to_hf('orders')
return jsonify({'success': True, 'message': 'Состав заказа обновлен'})
except (ValueError, TypeError) as e:
return jsonify({'success': False, 'message': 'Неверный формат данных'}), 400
except Exception as e:
logging.error(f"Error editing order items: {e}", exc_info=True)
return jsonify({'success': False, 'message': 'Внутренняя ошибка сервера'}), 500
@app.route('/admin/order/confirm/<order_id>', methods=['POST'])
@admin_required
def confirm_order(order_id):
orders = load_json_data('orders')
inventory = load_json_data('inventory')
transactions = load_json_data('transactions')
order = find_item_by_field(orders, 'id', order_id)
if not order or order.get('status') == 'Подтвержден':
flash("Заказ не найден или уже обработан.", "warning")
return redirect(url_for('online_orders'))
sale_items = []
total_amount = Decimal('0.00')
inventory_updates = defaultdict(lambda: {'product_id': None, 'stock_change': 0})
try:
for product_id, order_product in order.get('items', {}).items():
product_info = find_item_by_field(inventory, 'id', product_id)
if not product_info:
raise Exception(f"Товар '{order_product.get('name', 'N/A')}' не найден в инвентаре.")
for item_detail in order_product.get('items', []):
color = item_detail.get('color')
quantity_to_sell = item_detail.get('quantity', 0)
if quantity_to_sell <= 0: continue
variant_to_update = None
for v in product_info.get('variants', []):
if color in v.get('colors', []):
variant_to_update = v
break
if not variant_to_update:
raise Exception(f"Не найден подходящий вариант для товара '{product_info.get('name')}' с цветом '{color}'.")
if quantity_to_sell > variant_to_update.get('stock', 0):
raise Exception(f"Недостаточно товара '{product_info.get('name')} ({color})'. В наличии: {variant_to_update.get('stock', 0)}, требуется: {quantity_to_sell}.")
price_at_sale = to_decimal(variant_to_update.get('price', '0'))
cost_price_at_sale = to_decimal(variant_to_update.get('cost_price', '0'))
item_total = price_at_sale * Decimal(quantity_to_sell)
total_amount += item_total
sale_items.append({
'product_id': product_id,
'variant_id': variant_to_update['id'],
'name': f"{product_info['name']} ({color})",
'barcode': product_info.get('barcode'),
'quantity': quantity_to_sell,
'price_at_sale': str(price_at_sale),
'cost_price_at_sale': str(cost_price_at_sale),
'discount_per_item': '0.00',
'total': str(item_total)
})
inventory_updates[variant_to_update['id']]['product_id'] = product_id
inventory_updates[variant_to_update['id']]['stock_change'] += quantity_to_sell
now_iso = get_current_time().isoformat()
new_transaction = {
'id': uuid.uuid4().hex,
'timestamp': now_iso,
'type': 'online_sale',
'status': 'completed',
'user_name': 'Онлайн Каталог',
'kassa_name': 'Онлайн',
'shift_id': None,
'items': sale_items,
'total_amount': str(total_amount),
'payment_method': 'online'
}
transactions.append(new_transaction)
for variant_id, update_info in inventory_updates.items():
for p in inventory:
if p.get('id') == update_info['product_id']:
for v in p.get('variants', []):
if v.get('id') == variant_id:
v['stock'] -= update_info['stock_change']
p['timestamp_updated'] = now_iso
break
break
for o in orders:
if o['id'] == order_id:
o['status'] = 'Подтвержден'
break
save_json_data('inventory', inventory)
save_json_data('transactions', transactions)
save_json_data('orders', orders)
upload_db_to_hf('inventory')
upload_db_to_hf('transactions')
upload_db_to_hf('orders')
flash(f"Заказ №{order_id[:8]} успешно подтвержден.", "success")
except Exception as e:
flash(f"Ошибка при подтверждении заказа: {e}", "danger")
return redirect(url_for('online_orders'))
@app.route('/admin/order/delete/<order_id>', methods=['POST'])
@admin_required
def delete_order(order_id):
orders = load_json_data('orders')
initial_len = len(orders)
orders = [o for o in orders if o.get('id') != order_id]
if len(orders) < initial_len:
save_json_data('orders', orders)
upload_db_to_hf('orders')
flash(f"Заказ №{order_id[:8]} удален.", "success")
else:
flash("Заказ не найден.", "danger")
return redirect(url_for('online_orders'))
@app.route('/debts')
@admin_required
def debts_list():
debts = load_json_data('debts')
debts.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Долги").replace('__CONTENT__', DEBTS_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html, debts=debts)
@app.route('/debts/pay/<debt_id>', methods=['POST'])
@admin_required
def pay_debt(debt_id):
debts = load_json_data('debts')
debt_found = False
for debt in debts:
if debt.get('id') == debt_id:
debt['status'] = 'paid'
debt['paid_timestamp'] = get_current_time().isoformat()
debt_found = True
break
if debt_found:
save_json_data('debts', debts)
upload_db_to_hf('debts')
flash('Долг отмечен как погашенный.', 'success')
else:
flash('Долг не найден.', 'danger')
return redirect(url_for('debts_list'))
@app.route('/inventory', methods=['GET', 'POST'])
@admin_required
def inventory_management():
if request.method == 'POST':
try:
name = request.form.get('name', '').strip()
barcode = request.form.get('barcode', '').strip()
if not name or not barcode:
flash("Название и штрих-код - обязательные поля.", "danger")
return redirect(url_for('inventory_management'))
inventory = load_json_data('inventory')
if find_item_by_field(inventory, 'barcode', barcode):
flash(f"Товар со штрих-кодом {barcode} уже существует.", "warning")
return redirect(url_for('inventory_management'))
variants = []
variant_names = request.form.getlist('variant_name[]')
variant_prices = request.form.getlist('variant_price[]')
variant_cost_prices = request.form.getlist('variant_cost_price[]')
variant_stocks = request.form.getlist('variant_stock[]')
variant_image_urls_json = request.form.getlist('variant_image_urls[]')
variant_items_per_packs = request.form.getlist('variant_items_per_pack[]')
variant_colors_json = request.form.getlist('variant_colors[]')
for i in range(len(variant_names)):
v_name = variant_names[i].strip()
if not v_name: continue
try:
image_urls = json.loads(variant_image_urls_json[i]) if i < len(variant_image_urls_json) else []
colors = json.loads(variant_colors_json[i]) if i < len(variant_colors_json) else []
except json.JSONDecodeError:
image_urls = []
colors = []
variants.append({
'id': uuid.uuid4().hex,
'option_name': "Вариант",
'option_value': v_name,
'price': str(to_decimal(variant_prices[i])),
'cost_price': str(to_decimal(variant_cost_prices[i])),
'stock': int(to_decimal(variant_stocks[i], '0')),
'image_urls': image_urls,
'items_per_pack': int(variant_items_per_packs[i] if i < len(variant_items_per_packs) and variant_items_per_packs[i] else 1),
'colors': colors
})
if not variants:
flash("Нужно добавить хотя бы один вариант товара.", "danger")
return redirect(url_for('inventory_management'))
new_product = {
'id': uuid.uuid4().hex,
'name': name,
'barcode': barcode,
'variants': variants,
'timestamp_added': get_current_time().isoformat(),
'timestamp_updated': get_current_time().isoformat()
}
inventory.append(new_product)
save_json_data('inventory', inventory)
upload_db_to_hf('inventory')
flash(f"Товар '{name}' успешно добавлен.", "success")
except Exception as e:
logging.error(f"Error adding product: {e}", exc_info=True)
flash(f"Ошибка при добавлении товара: {e}", "danger")
return redirect(url_for('inventory_management'))
inventory_list = load_json_data('inventory')
inventory_list.sort(key=lambda x: x.get('name', '').lower())
total_units = 0
total_cost_value = Decimal('0.00')
total_retail_value = Decimal('0.00')
for product in inventory_list:
if isinstance(product, dict) and 'variants' in product:
for variant in product.get('variants', []):
stock = variant.get('stock', 0)
cost_price = to_decimal(variant.get('cost_price', '0'))
price = to_decimal(variant.get('price', '0'))
total_units += stock
total_cost_value += Decimal(stock) * cost_price
total_retail_value += Decimal(stock) * price
potential_profit = total_retail_value - total_cost_value
inventory_summary = {
'total_units': total_units,
'total_cost_value': total_cost_value,
'potential_profit': potential_profit
}
html = BASE_TEMPLATE.replace('__TITLE__', "Склад").replace('__CONTENT__', INVENTORY_CONTENT).replace('__SCRIPTS__', INVENTORY_SCRIPTS)
return render_template_string(html, inventory=inventory_list, inventory_summary=inventory_summary)
@app.route('/inventory/edit/<product_id>', methods=['POST'])
@admin_required
def edit_product(product_id):
inventory = load_json_data('inventory')
product_found = False
for i, product in enumerate(inventory):
if isinstance(product, dict) and product.get('id') == product_id:
try:
name = request.form.get('name', '').strip()
barcode = request.form.get('barcode', '').strip()
if not name or not barcode:
flash("Название и штрих-код обязательны.", "danger")
return redirect(url_for('inventory_management'))
existing_barcode = find_item_by_field(inventory, 'barcode', barcode)
if existing_barcode and existing_barcode.get('id') != product_id:
flash(f"Штрих-код {barcode} уже используется другим товаром.", "warning")
return redirect(url_for('inventory_management'))
inventory[i]['name'] = name
inventory[i]['barcode'] = barcode
new_variants = []
variant_ids = request.form.getlist('variant_id[]')
variant_names = request.form.getlist('variant_name[]')
variant_prices = request.form.getlist('variant_price[]')
variant_cost_prices = request.form.getlist('variant_cost_price[]')
variant_stocks = request.form.getlist('variant_stock[]')
variant_image_urls_json = request.form.getlist('variant_image_urls[]')
variant_items_per_packs = request.form.getlist('variant_items_per_pack[]')
variant_colors_json = request.form.getlist('variant_colors[]')
for j in range(len(variant_ids)):
v_name = variant_names[j].strip()
if not v_name: continue
try:
image_urls = json.loads(variant_image_urls_json[j]) if j < len(variant_image_urls_json) else []
colors = json.loads(variant_colors_json[j]) if j < len(variant_colors_json) else []
except json.JSONDecodeError:
image_urls = []
colors = []
new_variants.append({
'id': variant_ids[j] or uuid.uuid4().hex,
'option_name': "Вариант",
'option_value': v_name,
'price': str(to_decimal(variant_prices[j])),
'cost_price': str(to_decimal(variant_cost_prices[j])),
'stock': int(to_decimal(variant_stocks[j], '0')),
'image_urls': image_urls,
'items_per_pack': int(variant_items_per_packs[j] if j < len(variant_items_per_packs) and variant_items_per_packs[j] else 1),
'colors': colors
})
inventory[i]['variants'] = new_variants
inventory[i]['timestamp_updated'] = get_current_time().isoformat()
product_found = True
break
except Exception as e:
logging.error(f"Error updating product: {e}", exc_info=True)
flash(f"Ошибка при обновлении товара: {e}", "danger")
return redirect(url_for('inventory_management'))
if product_found:
save_json_data('inventory', inventory)
upload_db_to_hf('inventory')
flash("Товар успешно обновлен.", "success")
else:
flash("Товар не найден.", "danger")
return redirect(url_for('inventory_management'))
@app.route('/inventory/delete/<product_id>', methods=['POST'])
@admin_required
def delete_product(product_id):
inventory = load_json_data('inventory')
initial_len = len(inventory)
inventory = [p for p in inventory if not (isinstance(p, dict) and p.get('id') == product_id)]
if len(inventory) < initial_len:
save_json_data('inventory', inventory)
upload_db_to_hf('inventory')
flash("Товар удален.", "success")
else:
flash("Товар не найден.", "warning")
return redirect(url_for('inventory_management'))
@app.route('/inventory/stock_in', methods=['POST'])
@admin_required
def stock_in():
try:
product_id = request.form.get('product_id')
variant_id = request.form.get('variant_id')
quantity = int(request.form.get('quantity', 0))
cost_price_str = request.form.get('cost_price')
delivery_cost = to_decimal(request.form.get('delivery_cost', '0'))
if not product_id or not variant_id or quantity <= 0:
flash("Неверные данные для оприходования.", "danger")
return redirect(url_for('inventory_management'))
inventory = load_json_data('inventory')
product = find_item_by_field(inventory, 'id', product_id)
if not product:
flash("Товар не найден.", "danger")
return redirect(url_for('inventory_management'))
variant_found = False
variant_name_for_log = ""
for i, variant in enumerate(product.get('variants', [])):
if variant.get('id') == variant_id:
variant_name_for_log = variant.get('option_value', '')
old_stock = variant.get('stock', 0)
variant['stock'] = old_stock + quantity
old_cost = to_decimal(variant.get('cost_price', '0'))
new_cost = to_decimal(cost_price_str) if cost_price_str else old_cost
if old_stock + quantity > 0:
avg_cost = ((old_cost * old_stock) + (new_cost * quantity) + delivery_cost) / (old_stock + quantity)
variant['cost_price'] = str(avg_cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
variant_found = True
break
if not variant_found:
flash("Вариант товара не найден.", "danger")
return redirect(url_for('inventory_management'))
if delivery_cost > 0:
expenses = load_json_data('expenses')
new_expense = {
'id': uuid.uuid4().hex,
'timestamp': get_current_time().isoformat(),
'amount': str(delivery_cost),
'description': f"Дорога: {product['name']} ({variant_name_for_log})"
}
expenses.append(new_expense)
save_json_data('expenses', expenses)
upload_db_to_hf('expenses')
product['timestamp_updated'] = get_current_time().isoformat()
save_json_data('inventory', inventory)
upload_db_to_hf('inventory')
flash(f"Остаток товара '{product['name']} ({variant_name_for_log})' увеличен на {quantity}.", "success")
except Exception as e:
logging.error(f"Error stocking in: {e}", exc_info=True)
flash(f"Ошибка при оприходовании: {e}", "danger")
return redirect(url_for('inventory_management'))
@app.route('/upload_image', methods=['POST'])
@admin_required
def upload_image():
if 'image' not in request.files:
return jsonify({'success': False, 'message': 'No file part'}), 400
files = request.files.getlist('image')
if not files or files[0].filename == '':
return jsonify({'success': False, 'message': 'No selected files'}), 400
api = get_hf_api()
if not api:
return jsonify({'success': False, 'message': 'Hugging Face API not configured.'}), 500
uploaded_urls = []
try:
for file in files:
if file:
filename = str(uuid.uuid4()) + os.path.splitext(file.filename)[1]
save_path = os.path.join(app.static_folder, 'product_images', filename)
file.save(save_path)
path_in_repo = f'product_images/{filename}'
api.upload_file(
path_or_fileobj=save_path,
path_in_repo=path_in_repo,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
)
hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{path_in_repo}"
uploaded_urls.append(hf_url)
return jsonify({'success': True, 'urls': uploaded_urls})
except Exception as e:
logging.error(f"Image upload failed: {e}", exc_info=True)
return jsonify({'success': False, 'message': f'Server error: {e}'}), 500
@app.route('/api/product_by_barcode/<barcode>')
def get_product_by_barcode(barcode):
inventory = load_json_data('inventory')
product = find_item_by_field(inventory, 'barcode', barcode)
if product:
active_variants = [v for v in product.get('variants', []) if v.get('stock', 0) > 0]
if active_variants:
product_copy = product.copy()
product_copy['variants'] = active_variants
return jsonify({'success': True, 'product': product_copy})
else:
return jsonify({'success': False, 'message': 'Товар закончился на складе'}), 404
return jsonify({'success': False, 'message': 'Товар не найден'}), 404
@app.route('/api/complete_sale', methods=['POST'])
def complete_sale():
try:
data = request.get_json()
cart = data.get('cart', {})
user_id = data.get('userId')
kassa_id = data.get('kassaId')
shift_id = data.get('shiftId')
payment_method = data.get('paymentMethod', 'cash')
add_commission = data.get('addCommission', False)
add_loader_fee = data.get('addLoaderFee', False)
debt_info = data.get('debtInfo', {})
total_discount_str = data.get('totalDiscount', '0')
total_discount = to_decimal(total_discount_str)
if not cart or not user_id or not kassa_id or not shift_id:
return jsonify({'success': False, 'message': 'Неполные данные для продажи. Начните смену.'}), 400
inventory = load_json_data('inventory')
users = load_json_data('users')
kassas = load_json_data('kassas')
user = find_item_by_field(users, 'id', user_id)
kassa = find_item_by_field(kassas, 'id', kassa_id)
if not user or not kassa:
return jsonify({'success': False, 'message': 'Кассир или касса не найдены.'}), 404
sale_items = []
subtotal = Decimal('0.00')
inventory_updates = {}
for item_id, cart_item in cart.items():
if cart_item.get('isCustom'):
price_at_sale = to_decimal(cart_item.get('price', '0'))
quantity_sold = cart_item.get('quantity', 1)
item_total = price_at_sale * Decimal(quantity_sold)
subtotal += item_total
sale_items.append({
'product_id': None, 'variant_id': item_id,
'name': cart_item.get('productName', 'Товар без штрихкода'),
'barcode': 'CUSTOM', 'quantity': quantity_sold,
'price_at_sale': str(price_at_sale), 'cost_price_at_sale': '0.00',
'total': str(item_total),
'is_custom': True
})
continue
variant_id = item_id
product = find_item_by_field(inventory, 'id', cart_item['productId'])
if not product:
return jsonify({'success': False, 'message': f"Товар с ID {cart_item['productId']} не найден."}), 404
variant = find_item_by_field(product.get('variants', []), 'id', variant_id)
if not variant:
return jsonify({'success': False, 'message': f"Вариант товара с ID {variant_id} не найден."}), 404
quantity_sold = cart_item['quantity']
current_stock = variant.get('stock', 0)
if quantity_sold > current_stock:
return jsonify({'success': False, 'message': f"Недостаточно товара '{product['name']} ({variant['option_value']})'. В наличии: {current_stock}"}), 400
price_at_sale = to_decimal(variant.get('price', '0'))
cost_price_at_sale = to_decimal(variant.get('cost_price', '0'))
item_total = price_at_sale * Decimal(quantity_sold)
subtotal += item_total
sale_items.append({
'product_id': product['id'], 'variant_id': variant_id,
'name': f"{product['name']} ({variant['option_value']})",
'barcode': product.get('barcode'), 'quantity': quantity_sold,
'price_at_sale': str(price_at_sale), 'cost_price_at_sale': str(cost_price_at_sale),
'total': str(item_total)
})
inventory_updates[variant_id] = {'product_id': product['id'], 'new_stock': current_stock - quantity_sold}
loader_fee = Decimal('1000.00') if add_loader_fee else Decimal('0.00')
commission_rate = Decimal('0.95')
commission_fee = (subtotal * commission_rate / Decimal(100)) if add_commission else Decimal('0.00')
total_amount = subtotal - total_discount + loader_fee + commission_fee
now_iso = get_current_time().isoformat()
new_transaction = {
'id': uuid.uuid4().hex, 'timestamp': now_iso, 'type': 'sale', 'status': 'completed',
'original_transaction_id': None, 'user_id': user_id, 'user_name': user.get('name', 'N/A'),
'kassa_id': kassa_id, 'kassa_name': kassa.get('name', 'N/A'), 'shift_id': shift_id,
'items': sale_items, 'total_amount': str(total_amount), 'payment_method': payment_method,
'total_discount': str(total_discount), 'loader_fee': str(loader_fee), 'commission_fee': str(commission_fee),
'commission_rate': str(commission_rate) if add_commission else '0'
}
if payment_method == 'debt':
if not debt_info.get('name'):
return jsonify({'success': False, 'message': 'Имя и фамилия должника обязательны'}), 400
new_transaction['debt_info'] = debt_info
debts = load_json_data('debts')
new_debt = {
'id': uuid.uuid4().hex, 'transaction_id': new_transaction['id'],
'timestamp': now_iso, 'amount': str(total_amount), 'status': 'unpaid', **debt_info
}
debts.append(new_debt)
save_json_data('debts', debts)
upload_db_to_hf('debts')
new_transaction['invoice_html'] = generate_receipt_html(new_transaction)
transactions = load_json_data('transactions')
transactions.append(new_transaction)
for variant_id, update_info in inventory_updates.items():
for p in inventory:
if p.get('id') == update_info['product_id']:
for v in p.get('variants', []):
if v.get('id') == variant_id:
v['stock'] = update_info['new_stock']
p['timestamp_updated'] = now_iso
break
break
if payment_method == 'cash':
for i, k in enumerate(kassas):
if k.get('id') == kassa_id:
current_balance = to_decimal(k.get('balance', '0'))
kassas[i]['balance'] = str(current_balance + total_amount)
kassas[i].setdefault('history', []).append({
'type': 'sale', 'amount': str(total_amount), 'timestamp': now_iso,
'transaction_id': new_transaction['id']
})
break
save_json_data('transactions', transactions)
save_json_data('inventory', inventory)
save_json_data('kassas', kassas)
upload_db_to_hf('transactions')
upload_db_to_hf('inventory')
upload_db_to_hf('kassas')
receipt_url = url_for('view_receipt', transaction_id=new_transaction['id'], _external=True)
return jsonify({
'success': True, 'message': 'Продажа успешно зарегистрирована.',
'transactionId': new_transaction['id'], 'receiptUrl': receipt_url
})
except Exception as e:
logging.error(f"Error completing sale: {e}", exc_info=True)
return jsonify({'success': False, 'message': f'Внутренняя ошибка сервера: {e}'}), 500
@app.route('/receipt/<transaction_id>')
def view_receipt(transaction_id):
transactions = load_json_data('transactions')
transaction = find_item_by_field(transactions, 'id', transaction_id)
if transaction and 'invoice_html' in transaction:
return Response(transaction['invoice_html'], mimetype='text/html')
if transaction:
html = generate_receipt_html(transaction)
return Response(html, mimetype='text/html')
abort(404, description="Накладная не найдена")
@app.route('/transactions')
@admin_required
def transaction_history():
selected_date_str = request.args.get('date', get_current_time().strftime('%Y-%m-%d'))
selected_kassa_id = request.args.get('kassa', '')
try:
selected_date = datetime.strptime(selected_date_str, '%Y-%m-%d').date()
except ValueError:
selected_date = get_current_time().date()
selected_date_str = selected_date.strftime('%Y-%m-%d')
transactions = load_json_data('transactions')
kassas = load_json_data('kassas')
filtered_transactions = [
t for t in transactions
if datetime.fromisoformat(t['timestamp']).date() == selected_date
]
if selected_kassa_id:
filtered_transactions = [
t for t in filtered_transactions
if t.get('kassa_id') == selected_kassa_id
]
total_sales = sum(to_decimal(t['total_amount']) for t in filtered_transactions if t.get('type') == 'sale')
total_quantity_sold = 0
for t in filtered_transactions:
if t.get('type') == 'sale':
for item in t.get('items', []):
total_quantity_sold += int(item.get('quantity', 0))
filtered_transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "История транзакций").replace('__CONTENT__', TRANSACTIONS_CONTENT).replace('__SCRIPTS__', TRANSACTIONS_SCRIPTS)
return render_template_string(html, transactions=filtered_transactions, total_sales=total_sales, total_quantity_sold=total_quantity_sold, selected_date=selected_date_str, kassas=kassas, selected_kassa_id=selected_kassa_id)
@app.route('/admin/transaction/edit/<transaction_id>', methods=['POST'])
@admin_required
def edit_transaction(transaction_id):
try:
data = request.get_json()
items_update = data.get('items', [])
transactions = load_json_data('transactions')
kassas = load_json_data('kassas')
transaction_index = -1
for i, t in enumerate(transactions):
if t.get('id') == transaction_id:
transaction_index = i
break
if transaction_index == -1:
return jsonify({'success': False, 'message': 'Транзакция не найдена'}), 404
original_transaction = transactions[transaction_index]
old_total_amount = to_decimal(original_transaction['total_amount'])
new_subtotal = Decimal('0.00')
for item in original_transaction['items']:
item_id = item.get('variant_id') or item.get('product_id')
update_data = find_item_by_field(items_update, 'id', item_id)
if update_data:
new_price = to_decimal(update_data.get('price', item['price_at_sale']))
item['price_at_sale'] = str(new_price)
item_total = new_price * Decimal(item['quantity'])
item['total'] = str(item_total)
new_subtotal += to_decimal(item['total'])
total_discount = to_decimal(original_transaction.get('total_discount', '0'))
loader_fee = to_decimal(original_transaction.get('loader_fee', '0'))
commission_fee = to_decimal(original_transaction.get('commission_fee', '0'))
if original_transaction.get('commission_rate', '0') != '0':
commission_rate = to_decimal(original_transaction.get('commission_rate'))
commission_fee = new_subtotal * commission_rate / Decimal(100)
original_transaction['commission_fee'] = str(commission_fee)
new_total_amount = new_subtotal - total_discount + loader_fee + commission_fee
transactions[transaction_index]['total_amount'] = str(new_total_amount)
if 'edits' not in transactions[transaction_index]:
transactions[transaction_index]['edits'] = []
transactions[transaction_index]['edits'].append({
'timestamp': get_current_time().isoformat(),
'admin_user': session.get('admin_username', 'admin'),
'old_total': str(old_total_amount),
'new_total': str(new_total_amount)
})
transactions[transaction_index]['invoice_html'] = generate_receipt_html(transactions[transaction_index])
amount_diff = new_total_amount - old_total_amount
if amount_diff != Decimal(0) and original_transaction['payment_method'] == 'cash':
kassa_id = original_transaction.get('kassa_id')
for i, k in enumerate(kassas):
if k.get('id') == kassa_id:
k['balance'] = str(to_decimal(k.get('balance', '0')) + amount_diff)
k.setdefault('history', []).append({
'type': 'correction',
'amount': str(amount_diff),
'timestamp': get_current_time().isoformat(),
'description': f"Корректировка транзакции {transaction_id[:8]}"
})
break
save_json_data('kassas', kassas)
upload_db_to_hf('kassas')
save_json_data('transactions', transactions)
upload_db_to_hf('transactions')
flash("Транзакция успешно обновлена.", "success")
return jsonify({'success': True, 'message': 'Транзакция обновлена'})
except Exception as e:
logging.error(f"Error editing transaction: {e}", exc_info=True)
return jsonify({'success': False, 'message': f'Внутренняя ошибка: {e}'}), 500
@app.route('/admin/transaction/delete/<transaction_id>', methods=['POST'])
@admin_required
def delete_transaction(transaction_id):
transactions = load_json_data('transactions')
inventory = load_json_data('inventory')
kassas = load_json_data('kassas')
debts = load_json_data('debts')
transaction_to_delete = find_item_by_field(transactions, 'id', transaction_id)
if not transaction_to_delete:
flash("Транзакция не найдена.", "danger")
return redirect(url_for('transaction_history'))
for item in transaction_to_delete.get('items', []):
if item.get('is_custom'):
continue
product = find_item_by_field(inventory, 'id', item.get('product_id'))
if not product: continue
variant = find_item_by_field(product.get('variants', []), 'id', item.get('variant_id'))
if not variant: continue
quantity_change = item.get('quantity', 0)
if transaction_to_delete.get('type') == 'sale':
variant['stock'] = variant.get('stock', 0) + quantity_change
elif transaction_to_delete.get('type') == 'return':
variant['stock'] = variant.get('stock', 0) - quantity_change
if transaction_to_delete.get('payment_method') == 'cash':
kassa = find_item_by_field(kassas, 'id', transaction_to_delete.get('kassa_id'))
if kassa:
current_balance = to_decimal(kassa.get('balance', '0'))
amount_change = to_decimal(transaction_to_delete.get('total_amount'))
kassa['balance'] = str(current_balance - amount_change)
kassa.setdefault('history', []).append({
'type': 'deletion', 'amount': str(-amount_change),
'timestamp': get_current_time().isoformat(),
'description': f"Удаление транзакции {transaction_id[:8]}"
})
if transaction_to_delete.get('payment_method') == 'debt':
debts = [d for d in debts if d.get('transaction_id') != transaction_id]
save_json_data('debts', debts)
upload_db_to_hf('debts')
transactions = [t for t in transactions if t.get('id') != transaction_id]
if transaction_to_delete.get('type') == 'return':
original_id = transaction_to_delete.get('original_transaction_id')
if original_id:
for i, t in enumerate(transactions):
if t.get('id') == original_id:
transactions[i]['status'] = 'completed'
break
save_json_data('inventory', inventory)
save_json_data('kassas', kassas)
save_json_data('transactions', transactions)
upload_db_to_hf('inventory')
upload_db_to_hf('kassas')
upload_db_to_hf('transactions')
flash("Транзакция успешно удалена.", "success")
return redirect(url_for('transaction_history'))
@app.route('/reports')
@admin_required
def reports():
today = get_current_time().date()
start_date_str = request.args.get('start_date', (today.replace(day=1)).strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', (today).strftime('%Y-%m-%d'))
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').replace(tzinfo=ALMATY_TZ)
end_date = (datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)).replace(tzinfo=ALMATY_TZ)
num_days = (end_date - start_date).days
transactions = load_json_data('transactions')
expenses = load_json_data('expenses')
personal_expenses = load_json_data('personal_expenses')
users = load_json_data('users')
filtered_transactions = [
t for t in transactions
if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
]
filtered_expenses = [
e for e in expenses
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
]
filtered_personal_expenses = [
e for e in personal_expenses
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
]
return_transactions = [t for t in filtered_transactions if t.get('type') == 'return']
total_returns_amount = sum(to_decimal(t['total_amount']) for t in return_transactions)
total_returns_count = len(return_transactions)
total_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions)
total_cogs = sum(
to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity']))
for t in filtered_transactions for item in t['items']
)
gross_profit = total_revenue - total_cogs
online_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions if t.get('type') == 'online_sale')
total_expenses = sum(to_decimal(e['amount']) for e in filtered_expenses)
total_personal_expenses = sum(to_decimal(e['amount']) for e in filtered_personal_expenses)
sales_by_cashier = defaultdict(lambda: {'count': 0, 'total': Decimal(0)})
for t in filtered_transactions:
if t.get('type') == 'sale':
cashier_name = t.get('user_name', 'Неизвестный')
sales_by_cashier[cashier_name]['count'] += 1
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
elif t.get('type') == 'return':
cashier_name = t.get('user_name', 'Неизвестный')
sales_by_cashier[cashier_name]['count'] -= 1
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
cashier_payouts = defaultdict(Decimal)
for t in filtered_transactions:
if t.get('type') != 'sale': continue
user = find_item_by_field(users, 'id', t.get('user_id'))
if user:
payment_type = user.get('payment_type')
payment_value = to_decimal(user.get('payment_value', '0'))
if payment_type == 'percentage' and payment_value > 0:
payout = to_decimal(t['total_amount']) * (payment_value / Decimal(100))
cashier_payouts[user['name']] += payout
for user in users:
if user.get('payment_type') == 'salary':
monthly_salary = to_decimal(user.get('payment_value', '0'))
if monthly_salary > 0:
daily_salary = monthly_salary / Decimal(30)
period_salary = daily_salary * Decimal(num_days)
cashier_payouts[user['name']] += period_salary
total_salary_expenses = sum(cashier_payouts.values())
net_profit = gross_profit - total_expenses - total_personal_expenses - total_salary_expenses
stats = {
'total_revenue': total_revenue,
'online_revenue': online_revenue,
'total_returns_amount': abs(total_returns_amount),
'total_returns_count': total_returns_count,
'total_cogs': total_cogs,
'gross_profit': gross_profit,
'total_expenses': total_expenses,
'total_personal_expenses': total_personal_expenses,
'total_salary_expenses': total_salary_expenses,
'net_profit': net_profit,
'sales_by_cashier': sorted(sales_by_cashier.items(), key=lambda item: item[1]['total'], reverse=True),
'cashier_payouts': sorted(cashier_payouts.items(), key=lambda item: item[1], reverse=True)
}
filtered_expenses.sort(key=lambda x: x['timestamp'], reverse=True)
filtered_personal_expenses.sort(key=lambda x: x['timestamp'], reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS)
return render_template_string(html, stats=stats, start_date=start_date_str, end_date=end_date_str, expenses=filtered_expenses, personal_expenses=filtered_personal_expenses)
@app.route('/reports/product_roi')
@admin_required
def product_roi_report():
inventory = load_json_data('inventory')
transactions = load_json_data('transactions')
product_stats = []
for product in inventory:
for variant in product.get('variants', []):
total_revenue = Decimal('0.00')
total_cogs = Decimal('0.00')
total_qty_sold = 0
for t in transactions:
if t['type'] in ['sale', 'return', 'online_sale']:
for item in t['items']:
if item.get('variant_id') == variant['id']:
total_revenue += to_decimal(item['total'])
total_cogs += to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity']))
if t['type'] in ['sale', 'online_sale']:
total_qty_sold += item['quantity']
elif t['type'] == 'return':
total_qty_sold -= item['quantity']
current_stock = to_decimal(str(variant.get('stock', 0)))
cost_price = to_decimal(variant.get('cost_price', '0'))
inventory_value = current_stock * cost_price
total_investment = total_cogs + inventory_value
payback = total_revenue - total_investment
product_stats.append({
'name': product['name'],
'variant_name': variant['option_value'],
'total_qty_sold': total_qty_sold,
'total_revenue': total_revenue,
'total_investment': total_investment,
'inventory_value': inventory_value,
'payback': payback
})
product_stats.sort(key=lambda x: x['payback'], reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Отчет по окупаемости товаров").replace('__CONTENT__', PRODUCT_ROI_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html, stats=product_stats)
@app.route('/admin', methods=['GET'])
@admin_required
def admin_panel():
users = load_json_data('users')
kassas = load_json_data('kassas')
expenses = load_json_data('expenses')
personal_expenses = load_json_data('personal_expenses')
settings = load_json_data('settings', default_value={})
expenses.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
personal_expenses.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', ADMIN_SCRIPTS)
return render_template_string(html, users=users, kassas=kassas, expenses=expenses, personal_expenses=personal_expenses, settings=settings)
@app.route('/admin/settings', methods=['POST'])
@admin_required
def save_settings():
settings = load_json_data('settings', default_value={})
settings['whatsapp_number'] = request.form.get('whatsapp_number', '').strip()
save_json_data('settings', settings)
upload_db_to_hf('settings')
flash('Настройки сохранены.', 'success')
return redirect(url_for('admin_panel'))
@app.route('/admin/shifts')
@admin_required
def admin_shifts():
shifts = load_json_data('shifts')
shifts.sort(key=lambda x: x.get('start_time', ''), reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', "История смен").replace('__CONTENT__', SHIFTS_CONTENT)
return render_template_string(html, shifts=shifts)
@app.route('/admin/user', methods=['POST'])
@admin_required
def manage_user():
action = request.form.get('action')
users = load_json_data('users')
if action == 'add':
name = request.form.get('name', '').strip()
pin = request.form.get('pin', '').strip()
payment_type = request.form.get('payment_type')
payment_value = to_decimal(request.form.get('payment_value', '0'))
if name and pin and pin.isdigit() and payment_type:
new_user = {
'id': uuid.uuid4().hex, 'name': name, 'pin': pin,
'payment_type': payment_type, 'payment_value': str(payment_value)
}
users.append(new_user)
flash(f"Кассир '{name}' добавлен.", "success")
else:
flash("Все поля обязательны.", "danger")
elif action == 'edit':
user_id = request.form.get('id')
user = find_item_by_field(users, 'id', user_id)
if user:
user['name'] = request.form.get('name', '').strip()
user['pin'] = request.form.get('pin', '').strip()
user['payment_type'] = request.form.get('payment_type')
user['payment_value'] = str(to_decimal(request.form.get('payment_value', '0')))
flash(f"Данные кассира '{user['name']}' обновлены.", "success")
else:
flash("Кассир не найден.", "warning")
elif action == 'delete':
user_id = request.form.get('id')
initial_len = len(users)
users = [u for u in users if u.get('id') != user_id]
if len(users) < initial_len:
flash("Кассир удален.", "success")
else:
flash("Кассир не найден.", "warning")
save_json_data('users', users)
upload_db_to_hf('users')
return redirect(url_for('admin_panel'))
@app.route('/admin/kassa', methods=['POST'])
@admin_required
def manage_kassa():
action = request.form.get('action')
kassas = load_json_data('kassas')
if action == 'add':
name = request.form.get('name', '').strip()
balance = to_decimal(request.form.get('balance', '0'))
if name:
new_kassa = {
'id': uuid.uuid4().hex, 'name': name,
'balance': str(balance), 'history': []
}
if balance > 0:
new_kassa['history'].append({
'type': 'deposit', 'amount': str(balance),
'timestamp': get_current_time().isoformat(),
'description': 'Начальный баланс'
})
kassas.append(new_kassa)
flash(f"Касса '{name}' добавлена.", "success")
else:
flash("Название кассы обязательно.", "danger")
elif action == 'delete':
kassa_id = request.form.get('id')
initial_len = len(kassas)
kassas = [k for k in kassas if k.get('id') != kassa_id]
if len(kassas) < initial_len:
flash("Касса удалена.", "success")
else:
flash("Касса не найдена.", "warning")
save_json_data('kassas', kassas)
upload_db_to_hf('kassas')
return redirect(url_for('admin_panel'))
@app.route('/admin/kassa_op', methods=['POST'])
@admin_required
def kassa_operation():
kassa_id = request.form.get('kassa_id')
op_type = request.form.get('op_type')
amount = to_decimal(request.form.get('amount', '0'))
description = request.form.get('description', '').strip()
if not kassa_id or not op_type or amount <= 0:
flash("Выберите кассу, тип операции и укажите положительную сумму.", "danger")
return redirect(url_for('admin_panel'))
kassas = load_json_data('kassas')
kassa_found = False
for i, kassa in enumerate(kassas):
if kassa.get('id') == kassa_id:
current_balance = to_decimal(kassa.get('balance', '0'))
new_balance = current_balance
if op_type == 'deposit':
new_balance += amount
elif op_type == 'withdrawal':
if amount > current_balance:
flash("Сумма изъятия превышает баланс кассы.", "danger")
return redirect(url_for('admin_panel'))
new_balance -= amount
kassas[i]['balance'] = str(new_balance)
if 'history' not in kassas[i]: kassas[i]['history'] = []
kassas[i]['history'].append({
'type': op_type, 'amount': str(amount),
'timestamp': get_current_time().isoformat(),
'description': description or f"{'Внесение' if op_type == 'deposit' else 'Изъятие'} средств"
})
kassa_found = True
break
if kassa_found:
save_json_data('kassas', kassas)
upload_db_to_hf('kassas')
flash("Операция по кассе успешно проведена.", "success")
else:
flash("Касса не найдена.", "danger")
return redirect(url_for('admin_panel'))
@app.route('/admin/expense', methods=['POST'])
@admin_required
def manage_expense():
amount = to_decimal(request.form.get('amount', '0'))
description = request.form.get('description', '').strip()
if amount <= 0 or not description:
flash("Укажите положительную сумму и описание расхода.", "danger")
return redirect(url_for('admin_panel'))
new_expense = {
'id': uuid.uuid4().hex,
'timestamp': get_current_time().isoformat(),
'amount': str(amount),
'description': description
}
expenses = load_json_data('expenses')
expenses.append(new_expense)
save_json_data('expenses', expenses)
upload_db_to_hf('expenses')
flash("Расход успешно добавлен.", "success")
return redirect(url_for('admin_panel'))
@app.route('/admin/expense/delete/<expense_id>', methods=['POST'])
@admin_required
def delete_expense(expense_id):
expenses = load_json_data('expenses')
initial_len = len(expenses)
expenses = [e for e in expenses if e.get('id') != expense_id]
if len(expenses) < initial_len:
save_json_data('expenses', expenses)
upload_db_to_hf('expenses')
flash("Расход удален.", "success")
else:
flash("Расход не найден.", "warning")
return redirect(url_for('admin_panel'))
@app.route('/admin/personal_expense', methods=['POST'])
@admin_required
def manage_personal_expense():
amount = to_decimal(request.form.get('amount', '0'))
description = request.form.get('description', '').strip()
if amount <= 0 or not description:
flash("Укажите положительную сумму и описание личного расхода.", "danger")
return redirect(url_for('admin_panel'))
new_expense = {
'id': uuid.uuid4().hex,
'timestamp': get_current_time().isoformat(),
'amount': str(amount),
'description': description
}
expenses = load_json_data('personal_expenses')
expenses.append(new_expense)
save_json_data('personal_expenses', expenses)
upload_db_to_hf('personal_expenses')
flash("Личный расход успешно добавлен.", "success")
return redirect(url_for('admin_panel'))
@app.route('/admin/personal_expense/delete/<expense_id>', methods=['POST'])
@admin_required
def delete_personal_expense(expense_id):
expenses = load_json_data('personal_expenses')
initial_len = len(expenses)
expenses = [e for e in expenses if e.get('id') != expense_id]
if len(expenses) < initial_len:
save_json_data('personal_expenses', expenses)
upload_db_to_hf('personal_expenses')
flash("Личный расход удален.", "success")
else:
flash("Личный расход не найден.", "warning")
return redirect(url_for('admin_panel'))
@app.route('/cashier_login', methods=['GET', 'POST'])
def cashier_login():
if request.method == 'POST':
pin = request.form.get('pin')
user = find_user_by_pin(pin)
if user:
return redirect(url_for('cashier_dashboard', user_id=user['id']))
else:
flash("Неверный ПИН-код.", "danger")
html = BASE_TEMPLATE.replace('__TITLE__', "Вход для кассира").replace('__CONTENT__', CASHIER_LOGIN_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html)
@app.route('/api/verify_pin', methods=['POST'])
def verify_pin():
pin = request.json.get('pin')
user = find_user_by_pin(pin)
if user:
return jsonify({'success': True, 'user': {'id': user['id'], 'name': user['name']}})
else:
return jsonify({'success': False, 'message': 'Неверный ПИН-код'}), 401
@app.route('/api/shift/start', methods=['POST'])
def start_shift():
data = request.json
user_id = data.get('userId')
kassa_id = data.get('kassaId')
if not user_id:
return jsonify({'success': False, 'message': 'Missing user ID'}), 400
shifts = load_json_data('shifts')
open_shift = next((s for s in shifts if s.get('user_id') == user_id and s.get('end_time') is None), None)
if open_shift:
return jsonify({'success': True, 'shift': open_shift})
if not kassa_id:
return jsonify({'success': False, 'message': 'Выберите кассу для начала новой смены'}), 400
users = load_json_data('users')
kassas = load_json_data('kassas')
user = find_item_by_field(users, 'id', user_id)
kassa = find_item_by_field(kassas, 'id', kassa_id)
if not user or not kassa:
return jsonify({'success': False, 'message': 'Кассир или касса не найдены'}), 404
new_shift = {
'id': uuid.uuid4().hex, 'user_id': user_id, 'user_name': user['name'],
'kassa_id': kassa_id, 'kassa_name': kassa['name'],
'start_time': get_current_time().isoformat(),
'start_balance': kassa.get('balance', '0'),
'end_time': None
}
shifts.append(new_shift)
save_json_data('shifts', shifts)
upload_db_to_hf('shifts')
return jsonify({'success': True, 'shift': new_shift})
@app.route('/api/shift/end', methods=['POST'])
def end_shift():
data = request.json
shift_id = data.get('shiftId')
if not shift_id:
return jsonify({'success': False, 'message': 'Missing shift ID'}), 400
shifts = load_json_data('shifts')
kassas = load_json_data('kassas')
transactions = load_json_data('transactions')
shift_found = False
for i, shift in enumerate(shifts):
if shift.get('id') == shift_id:
if shift.get('end_time'):
return jsonify({'success': False, 'message': 'Смена уже закрыта'}), 400
shift['end_time'] = get_current_time().isoformat()
kassa = find_item_by_field(kassas, 'id', shift['kassa_id'])
shift['end_balance'] = kassa.get('balance', '0') if kassa else '0'
shift_transactions = [
t for t in transactions
if t.get('shift_id') == shift_id and datetime.fromisoformat(t['timestamp']) >= datetime.fromisoformat(shift['start_time'])
]
cash_sales = sum(to_decimal(t['total_amount']) for t in shift_transactions if t['type'] == 'sale' and t['payment_method'] == 'cash')
card_sales = sum(to_decimal(t['total_amount']) for t in shift_transactions if t['type'] == 'sale' and t['payment_method'] == 'card')
shift['cash_sales'] = str(cash_sales)
shift['card_sales'] = str(card_sales)
shift['total_sales'] = str(cash_sales + card_sales)
shifts[i] = shift
shift_found = True
break
if not shift_found:
return jsonify({'success': False, 'message': 'Смена не найдена'}), 404
save_json_data('shifts', shifts)
upload_db_to_hf('shifts')
return jsonify({'success': True, 'message': 'Смена успешно закрыта'})
@app.route('/cashier_dashboard/<user_id>')
def cashier_dashboard(user_id):
users = load_json_data('users')
user = find_item_by_field(users, 'id', user_id)
if not user:
abort(404, "Кассир не найден")
transactions = load_json_data('transactions')
user_transactions = [t for t in transactions if t.get('user_id') == user_id]
user_transactions.sort(key=lambda x: x['timestamp'], reverse=True)
html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {user['name']}").replace('__CONTENT__', CASHIER_DASHBOARD_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html, user=user, transactions=user_transactions)
@app.route('/return_transaction/<transaction_id>', methods=['GET', 'POST'])
def return_transaction(transaction_id):
transactions = load_json_data('transactions')
inventory = load_json_data('inventory')
kassas = load_json_data('kassas')
original_transaction_index = -1
for i, t in enumerate(transactions):
if t.get('id') == transaction_id:
original_transaction_index = i
break
if original_transaction_index == -1:
flash("Оригинальная транзакция не найдена.", "danger")
return redirect(url_for('cashier_login'))
original_transaction = transactions[original_transaction_index]
if request.method == 'GET':
cashier_id = request.args.get('cashier_id')
if not cashier_id:
flash("Не указан ID кассира.", "danger")
return redirect(url_for('cashier_login'))
returnable_items = []
already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
for item in original_transaction['items']:
variant_id = item.get('variant_id')
returned_qty = already_returned.get(variant_id, 0)
max_returnable = item['quantity'] - returned_qty
if max_returnable > 0:
item_copy = item.copy()
item_copy['max_returnable'] = max_returnable
returnable_items.append(item_copy)
html = BASE_TEMPLATE.replace('__TITLE__', f"Возврат по накладной {transaction_id[:8]}").replace('__CONTENT__', RETURN_PAGE_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html, transaction=original_transaction, items=returnable_items, cashier_id=cashier_id)
if request.method == 'POST':
cashier_id = request.form.get('cashier_id')
if not cashier_id:
flash("Не удалось определить кассира.", "danger")
return redirect(url_for('cashier_login'))
return_items = []
total_return_amount = Decimal('0.00')
inventory_updates = {}
items_to_process = defaultdict(int)
for key, value in request.form.items():
if key.startswith('return_qty_'):
variant_id = key.replace('return_qty_', '')
try:
qty = int(value)
if qty > 0:
items_to_process[variant_id] = qty
except (ValueError, TypeError):
continue
if not items_to_process:
flash("Не выбрано ни одного товара для возврата.", "warning")
return redirect(url_for('return_transaction', transaction_id=transaction_id, cashier_id=cashier_id))
already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
total_items_in_sale = 0
total_items_returned_before = sum(already_returned.values())
for item in original_transaction['items']:
variant_id = item.get('variant_id')
total_items_in_sale += item['quantity']
if variant_id in items_to_process:
qty_to_return = items_to_process[variant_id]
returned_so_far = already_returned.get(variant_id, 0)
if qty_to_return > (item['quantity'] - returned_so_far):
flash(f"Нельзя вернуть {qty_to_return} шт. товара '{item['name']}', т.к. доступно к возврату {item['quantity'] - returned_so_far}.", "danger")
return redirect(url_for('return_transaction', transaction_id=transaction_id, cashier_id=cashier_id))
price = to_decimal(item['price_at_sale'])
item_total = price * qty_to_return
total_return_amount += item_total
return_items.append({**item, 'quantity': qty_to_return, 'total': str(item_total)})
if not item.get('is_custom'):
product = find_item_by_field(inventory, 'id', item['product_id'])
if product:
variant = find_item_by_field(product.get('variants', []), 'id', variant_id)
if variant:
inventory_updates[variant_id] = {'product_id': item['product_id'], 'stock_change': qty_to_return}
now_iso = get_current_time().isoformat()
return_transaction_id = uuid.uuid4().hex
return_transaction = {
'id': return_transaction_id, 'timestamp': now_iso, 'type': 'return', 'status': 'completed',
'original_transaction_id': transaction_id,
'user_id': original_transaction['user_id'], 'user_name': original_transaction['user_name'],
'kassa_id': original_transaction['kassa_id'], 'kassa_name': original_transaction['kassa_name'],
'shift_id': original_transaction.get('shift_id'),
'items': return_items, 'total_amount': str(-total_return_amount),
'payment_method': original_transaction['payment_method']
}
transactions.append(return_transaction)
return_info = original_transaction.setdefault('return_info', {'returned_items': {}, 'return_transaction_ids': []})
return_info['return_transaction_ids'].append(return_transaction_id)
total_items_returned_now = 0
for variant_id, qty in items_to_process.items():
return_info['returned_items'][variant_id] = return_info['returned_items'].get(variant_id, 0) + qty
total_items_returned_now += qty
if (total_items_returned_before + total_items_returned_now) >= total_items_in_sale:
original_transaction['status'] = 'returned'
else:
original_transaction['status'] = 'partially_returned'
transactions[original_transaction_index] = original_transaction
for variant_id, update_info in inventory_updates.items():
for p_idx, p in enumerate(inventory):
if p.get('id') == update_info['product_id']:
for v_idx, v in enumerate(p.get('variants', [])):
if v.get('id') == variant_id:
inventory[p_idx]['variants'][v_idx]['stock'] += update_info['stock_change']
inventory[p_idx]['timestamp_updated'] = now_iso
break
break
if original_transaction['payment_method'] == 'cash' and total_return_amount > 0:
for k_idx, k in enumerate(kassas):
if k.get('id') == original_transaction['kassa_id']:
current_balance = to_decimal(k.get('balance', '0'))
kassas[k_idx]['balance'] = str(current_balance - total_return_amount)
kassas[k_idx].setdefault('history', []).append({
'type': 'return', 'amount': str(-total_return_amount), 'timestamp': now_iso,
'transaction_id': return_transaction_id
})
break
save_json_data('transactions', transactions)
save_json_data('inventory', inventory)
save_json_data('kassas', kassas)
upload_db_to_hf('transactions')
upload_db_to_hf('inventory')
upload_db_to_hf('kassas')
flash("Возврат успешно оформлен.", "success")
return redirect(url_for('cashier_dashboard', user_id=cashier_id))
@app.route('/backup', methods=['POST'])
@admin_required
def backup_hf():
try:
for key in DATA_FILES.keys():
upload_db_to_hf(key)
flash(f"Резервное копирование {len(DATA_FILES)} файлов инициировано.", "success")
except Exception as e:
flash(f"Ошибка при резервном копировании: {e}", "danger")
return redirect(url_for('admin_panel'))
@app.route('/download', methods=['GET'])
@admin_required
def download_hf():
errors = []
success_count = 0
for key in DATA_FILES.keys():
filepath, _ = DATA_FILES[key]
filename = os.path.basename(filepath)
try:
hf_hub_download(
repo_id=REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN_READ,
local_dir=DATA_DIR, local_dir_use_symlinks=False, force_download=True,
)
success_count += 1
except Exception as e:
errors.append(f"Ошибка загрузки {filename}: {e}")
try:
sync_images_from_hf(force=True)
flash("Синхронизация изображений завершена.", "info")
except Exception as e:
errors.append(f"Ошибка синхронизации изображений: {e}")
if success_count > 0:
flash(f"Успешно загружено {success_count} файлов. Данные перезаписаны.", "success")
if errors:
flash("Произошли ошибки: " + "; ".join(errors), "danger")
return redirect(url_for('admin_panel'))
@app.route('/admin_login', methods=['GET', 'POST'])
def admin_login():
if request.method == 'POST':
password = request.form.get('password')
if password == ADMIN_PASS:
session['admin_logged_in'] = True
session.permanent = True
next_url = request.args.get('next')
flash("Вы успешно вошли в систему.", "success")
return redirect(next_url or url_for('admin_panel'))
else:
flash("Неверный пароль.", "danger")
html = BASE_TEMPLATE.replace('__TITLE__', "Вход для администратора").replace('__CONTENT__', ADMIN_LOGIN_CONTENT).replace('__SCRIPTS__', '')
return render_template_string(html)
@app.route('/admin_logout')
def admin_logout():
session.pop('admin_logged_in', None)
flash("Вы вышли из системы.", "info")
return redirect(url_for('sales_screen'))
BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__ - POS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --sidebar-width: 250px; }
body { background-color: #f8f9fa; }
.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background-color: #343a40; padding-top: 1rem; }
.sidebar .nav-link { color: rgba(255,255,255,.75); }
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: #fff; }
.main-content { margin-left: var(--sidebar-width); padding: 1.5rem; }
@media (max-width: 992px) {
.sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); transition: transform 0.3s ease-in-out; z-index: 1040; }
.sidebar.active { transform: translateX(0); }
.main-content { margin-left: 0; }
}
[data-bs-theme="dark"] body { background-color: #212529; color: #dee2e6; }
[data-bs-theme="dark"] .card, [data-bs-theme="dark"] .modal-content, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table, [data-bs-theme="dark"] .accordion-item { background-color: #343a40; }
[data-bs-theme="dark"] .accordion-button { background-color: #3e444a; color: #fff; }
[data-bs-theme="dark"] .accordion-button:not(.collapsed) { background-color: #495057;}
[data-bs-theme="dark"] .accordion-button::after { filter: invert(1) grayscale(100) brightness(200%); }
[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); }
[data-bs-theme="dark"] .text-dark { color: #dee2e6 !important; }
.product-card { cursor: pointer; }
.product-card:hover { border-color: var(--bs-primary); }
.payback-positive { color: var(--bs-success); }
.payback-negative { color: var(--bs-danger); }
.payback-zero { color: var(--bs-secondary); }
.notification-badge { position: absolute; top: -5px; right: -8px; padding: 0.25em 0.5em; font-size: 0.75rem; }
</style>
</head>
<body>
<nav class="sidebar">
<div class="p-3 text-white">
<a href="/" class="text-white text-decoration-none"><h4 class="fw-bold"><i class="fas fa-cash-register me-2"></i>POS System</h4></a>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="{{ url_for('shop_catalog') }}" target="_blank"><i class="fas fa-fw fa-store me-2"></i>Витрина магазина</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'sales_screen' %}active{% endif %}" href="{{ url_for('sales_screen') }}"><i class="fas fa-fw fa-dollar-sign me-2"></i>Касса</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'inventory_management' %}active{% endif %}" href="{{ url_for('inventory_management') }}"><i class="fas fa-fw fa-boxes me-2"></i>Склад</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'transaction_history' %}active{% endif %}" href="{{ url_for('transaction_history') }}"><i class="fas fa-fw fa-history me-2"></i>Транзакции</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint in ['reports', 'product_roi_report'] %}active{% endif %}" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-fw fa-chart-line me-2"></i>Отчеты
</a>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="{{ url_for('reports') }}">Сводный отчет</a></li>
<li><a class="dropdown-item" href="{{ url_for('product_roi_report') }}">Окупаемость товаров</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'debts_list' %}active{% endif %}" href="{{ url_for('debts_list') }}"><i class="fas fa-fw fa-book me-2"></i>Долги</a></li>
<li class="nav-item position-relative"><a class="nav-link {% if request.endpoint == 'online_orders' %}active{% endif %}" href="{{ url_for('online_orders') }}"><i class="fas fa-fw fa-globe me-2"></i>Онлайн заказы</a> {% if new_order_count > 0 %}<span class="badge rounded-pill bg-danger notification-badge">{{ new_order_count }}</span>{% endif %}</li>
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_login', 'cashier_dashboard'] %}active{% endif %}" href="{{ url_for('cashier_login') }}"><i class="fas fa-fw fa-user-circle me-2"></i>Кабинет кассира</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['admin_panel', 'admin_shifts'] %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cogs me-2"></i>Админка</a></li>
{% if session.admin_logged_in %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_logout') }}"><i class="fas fa-fw fa-sign-out-alt me-2"></i>Выйти (Админ)</a></li>
{% endif %}
</ul>
<div class="mt-auto p-3 text-secondary small">
&copy; {{ get_current_time().year }}<br>{{ get_current_time().strftime('%Y-%m-%d %H:%M') }}
</div>
</nav>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<button class="btn btn-dark d-lg-none position-relative" id="sidebar-toggle">
<i class="fas fa-bars"></i>
{% if new_order_count > 0 %}<span class="badge rounded-pill bg-danger notification-badge">{{ new_order_count }}</span>{% endif %}
</button>
<h2 class="h3 mb-0">__TITLE__</h2>
<div id="theme-toggle" style="cursor: pointer;"><i class="fas fa-sun fa-lg"></i></div>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
__CONTENT__
</main>
</div>
<div class="modal fade" id="receiptModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Продажа завершена</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Накладная успешно создана.</p>
<div class="mb-3">
<label for="whatsapp-phone" class="form-label">Отправить накладную на WhatsApp</label>
<div class="input-group">
<span class="input-group-text">+7</span>
<input type="tel" class="form-control" id="whatsapp-phone" placeholder="7071234567">
</div>
<div class="form-text">Введите номер без +7.</div>
</div>
<input type="hidden" id="receipt-url">
</div>
<div class="modal-footer">
<a id="view-receipt-btn" href="#" target="_blank" class="btn btn-secondary">Посмотреть накладную</a>
<button type="button" id="send-whatsapp-btn" class="btn btn-success"><i class="fab fa-whatsapp"></i> Отправить</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="variantSelectModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="variantSelectModalTitle">Выберите вариант</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="variant-list" class="list-group"></div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('sidebar-toggle')?.addEventListener('click', () => document.querySelector('.sidebar').classList.toggle('active'));
const themeToggle = document.getElementById('theme-toggle');
const getStoredTheme = () => localStorage.getItem('theme');
const setStoredTheme = theme => localStorage.setItem('theme', theme);
const getPreferredTheme = () => getStoredTheme() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const setTheme = theme => {
document.documentElement.setAttribute('data-bs-theme', theme);
themeToggle.innerHTML = theme === 'dark' ? '<i class="fas fa-moon fa-lg"></i>' : '<i class="fas fa-sun fa-lg"></i>';
};
setTheme(getPreferredTheme());
themeToggle.addEventListener('click', () => {
const newTheme = getPreferredTheme() === 'light' ? 'dark' : 'light';
setStoredTheme(newTheme);
setTheme(newTheme);
});
});
</script>
__SCRIPTS__
</body>
</html>
"""
SHOP_BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__ - Maks Cap</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@500&family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bs-primary: #003B6D;
--bs-primary-rgb: 0, 59, 109;
--bs-body-bg: #f8f9fa;
}
body {
font-family: 'Montserrat', sans-serif;
background-color: var(--bs-body-bg);
padding-top: 70px;
}
.navbar-brand {
font-family: 'EB Garamond', serif;
font-weight: 500;
font-size: 2rem;
}
.navbar {
background-color: var(--bs-primary);
box-shadow: 0 2px 4px rgba(0,0,0,.1);
}
.product-card {
border: 1px solid var(--bs-border-color-translucent);
border-radius: .75rem;
overflow: hidden;
background-color: var(--bs-card-bg);
transition: transform .2s ease-in-out, box-shadow .2s ease-in-out;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,.1);
}
.product-card .carousel-item img {
aspect-ratio: 1 / 1;
object-fit: cover;
}
.product-card .card-body { padding: 1rem; }
.product-card .card-title {
font-weight: 700;
font-size: 1rem;
color: var(--bs-body-color);
margin-bottom: .25rem;
}
.product-card .price-range {
font-weight: 700;
color: var(--bs-primary);
font-size: 1.1rem;
}
.main-title {
font-family: 'EB Garamond', serif;
font-weight: 500;
color: var(--bs-body-color);
}
#productDetailModal .modal-content {
border: none;
border-radius: 0.5rem;
}
[data-bs-theme="dark"] {
--bs-body-bg: #121212;
--bs-card-bg: #1e1e1e;
--bs-border-color: rgba(255, 255, 255, 0.15);
--bs-border-color-translucent: rgba(255, 255, 255, 0.1);
--bs-body-color: #dee2e6;
--bs-secondary-bg: #343a40;
--bs-primary: #0d6efd;
}
[data-bs-theme="dark"] .product-card:hover {
box-shadow: 0 8px 25px rgba(0,0,0,.3);
}
[data-bs-theme="dark"] .navbar {
background-color: #1e1e1e !important;
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .offcanvas, [data-bs-theme="dark"] .modal-content {
background-color: #1e1e1e;
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .list-group-item {
background-color: transparent;
border-color: var(--bs-border-color-translucent);
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark fixed-top">
<div class="container">
<a class="navbar-brand" href="{{ url_for('shop_catalog') }}">
<i class="fas fa-water me-2"></i>Maks Cap
</a>
<div class="d-flex align-items-center">
<button class="btn btn-outline-light me-3" type="button" data-bs-toggle="offcanvas" data-bs-target="#cartOffcanvas" aria-controls="cartOffcanvas">
<i class="fas fa-shopping-cart"></i>
<span class="badge bg-danger rounded-pill" id="cart-item-count">0</span>
</button>
<div id="theme-toggle" style="cursor: pointer;" class="text-white"><i class="fas fa-sun fa-lg"></i></div>
</div>
</div>
</nav>
<main class="container my-4 my-md-5">
<h1 class="main-title mb-4 text-center">__TITLE__</h1>
__CONTENT__
</main>
<div class="modal fade" id="productDetailModal" tabindex="-1" aria-labelledby="productDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content" id="productDetailModalBody">
</div>
</div>
</div>
<div class="offcanvas offcanvas-end" tabindex="-1" id="cartOffcanvas" aria-labelledby="cartOffcanvasLabel">
<div class="offcanvas-header border-bottom">
<h5 id="cartOffcanvasLabel">Корзина</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body d-flex flex-column">
<div id="cart-content" class="flex-grow-1">
<p class="text-muted text-center pt-3">Корзина пуста.</p>
</div>
<div class="mt-auto border-top pt-3">
<div id="cart-summary"></div>
<div class="d-grid">
<button class="btn btn-primary" id="create-order-btn">Оформить заказ</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
const getStoredTheme = () => localStorage.getItem('theme');
const setStoredTheme = theme => localStorage.setItem('theme', theme);
const getPreferredTheme = () => getStoredTheme() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const setTheme = theme => {
document.documentElement.setAttribute('data-bs-theme', theme);
themeToggle.innerHTML = theme === 'dark' ? '<i class="fas fa-moon fa-lg"></i>' : '<i class="fas fa-sun fa-lg"></i>';
};
setTheme(getPreferredTheme());
themeToggle.addEventListener('click', () => {
const newTheme = getPreferredTheme() === 'light' ? 'dark' : 'light';
setStoredTheme(newTheme);
setTheme(newTheme);
});
});
</script>
__SCRIPTS__
</body>
</html>
"""
SALES_SCREEN_CONTENT = """
<div class="row">
<div class="col-lg-7 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Товары</h5>
<div>
<button id="custom-item-btn" class="btn btn-info btn-sm"><i class="fas fa-plus me-2"></i>Свой товар</button>
<button id="scan-btn" class="btn btn-primary btn-sm"><i class="fas fa-barcode me-2"></i>Сканировать</button>
</div>
</div>
<div class="card-body">
<div id="scanner-container" class="mb-3" style="display: none; position: relative;">
<div id="reader" style="width: 100%;"></div>
<div id="scanner-status" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.5); color: white; padding: 5px; border-radius: 5px; display: none;"></div>
<button id="stop-scan-btn" class="btn btn-danger btn-sm mt-2">Остановить</button>
</div>
<input type="text" id="product-search" class="form-control mb-3" placeholder="Поиск по названию или штрих-коду...">
<div id="product-accordion" class="accordion">
{% for letter, products in grouped_inventory %}
<div class="accordion-item">
<h2 class="accordion-header" id="heading-{{ letter }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ letter }}" aria-expanded="false" aria-controls="collapse-{{ letter }}">
{{ letter }}
</button>
</h2>
<div id="collapse-{{ letter }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ letter }}" data-bs-parent="#product-accordion">
<div class="accordion-body d-grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));">
{% for p in products %}
<div class="card text-center product-card" data-barcode="{{ p.barcode }}">
<div class="card-body p-2">
<h6 class="card-title small mb-1">{{ p.name }}</h6>
<p class="card-text fw-bold mb-0">
{% if p.variants|length > 1 %}
от {{ format_currency_py(p.variants|map(attribute='price')|min) }} ₸
{% elif p.variants|length == 1 %}
{{ format_currency_py(p.variants[0].price) }} ₸
{% else %}
Нет в наличии
{% endif %}
</p>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">Накладная</h5>
<div id="shift-controls"></div>
</div>
<div id="session-info" class="small text-muted mt-1"></div>
</div>
<div class="card-body">
<div id="cart-items" class="list-group mb-3" style="max-height: 400px; overflow-y: auto;"></div>
<div id="cart-summary"></div>
<div class="input-group mb-3">
<span class="input-group-text">Общая скидка</span>
<input type="text" id="total-discount-input" class="form-control" value="0" inputmode="decimal">
<span class="input-group-text">₸</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Итого:</h4>
<h4 class="mb-0" id="cart-total">0,00 ₸</h4>
</div>
<div class="d-grid gap-2">
<div class="btn-group">
<button class="btn btn-success flex-grow-1" id="pay-cash-btn"><i class="fas fa-money-bill-wave me-2"></i>Наличные</button>
<button class="btn btn-info flex-grow-1" id="pay-card-btn"><i class="far fa-credit-card me-2"></i>Карта</button>
<button class="btn btn-warning flex-grow-1" id="pay-debt-btn"><i class="fas fa-book me-2"></i>В долг</button>
</div>
<div class="d-flex justify-content-around mt-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="commission-check">
<label class="form-check-label small" for="commission-check">Комиссия 0.95%</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="loader-check">
<label class="form-check-label small" for="loader-check">Грузчик (+1000 ₸)</label>
</div>
</div>
<button class="btn btn-danger mt-2" id="clear-cart-btn">Очистить</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="cashierLoginModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Вход для кассира</h5></div>
<div class="modal-body">
<label for="cashier-pin-input" class="form-label">Введите ПИН-код</label>
<input type="password" id="cashier-pin-input" class="form-control form-control-lg text-center" inputmode="numeric" autofocus>
<div id="pin-error" class="text-danger mt-2"></div>
</div>
<div class="modal-footer"><button type="button" id="pin-submit-btn" class="btn btn-primary">Войти</button></div>
</div>
</div>
</div>
<div class="modal fade" id="startShiftModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Начать смену</h5></div>
<div class="modal-body">
<p>Кассир: <strong id="start-shift-cashier-name"></strong></p>
<label for="kassa-select-modal" class="form-label">Выберите кассу</label>
<select id="kassa-select-modal" class="form-select">
<option value="">-- Выберите кассу --</option>
{% for k in kassas %}<option value="{{ k.id }}" data-name="{{ k.name }}">{{ k.name }}</option>{% endfor %}
</select>
</div>
<div class="modal-footer">
<button type="button" id="logout-btn" class="btn btn-secondary me-auto">Сменить кассира</button>
<button type="button" id="start-shift-confirm-btn" class="btn btn-primary">Начать</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="customItemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Товар без штрихкода</h5></div>
<form id="custom-item-form">
<div class="modal-body">
<div class="mb-3"><label class="form-label">Название (необязательно)</label><input type="text" id="custom-item-name" class="form-control" placeholder="Напр. 'Пакет'"></div>
<div class="mb-3"><label class="form-label">Цена за 1 шт.</label><input type="text" id="custom-item-price" class="form-control" inputmode="decimal" required></div>
<div class="mb-3"><label class="form-label">Количество</label><input type="number" id="custom-item-qty" class="form-control" value="1" min="1" required></div>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Добавить в накладную</button></div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="debtModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Оформление в долг</h5></div>
<div class="modal-body">
<div class="mb-3"><label for="debtor-name" class="form-label">Имя и Фамилия</label><input type="text" id="debtor-name" class="form-control" required></div>
<div class="mb-3"><label for="debt-due-date" class="form-label">Примерный срок</label><input type="text" id="debt-due-date" class="form-control" placeholder="Напр. 'До конца недели'"></div>
</div>
<div class="modal-footer"><button type="button" id="confirm-debt-btn" class="btn btn-primary">Подтвердить</button></div>
</div>
</div>
</div>
"""
SALES_SCREEN_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', () => {
const cart = {};
const productGrid = document.getElementById('product-accordion');
const cartItemsEl = document.getElementById('cart-items');
const cartSummaryEl = document.getElementById('cart-summary');
const cartTotalEl = document.getElementById('cart-total');
const commissionCheck = document.getElementById('commission-check');
const loaderCheck = document.getElementById('loader-check');
const totalDiscountInput = document.getElementById('total-discount-input');
let audioCtx;
let isScannerPaused = false;
const receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal'));
const cashierLoginModal = new bootstrap.Modal(document.getElementById('cashierLoginModal'));
const startShiftModal = new bootstrap.Modal(document.getElementById('startShiftModal'));
const customItemModal = new bootstrap.Modal(document.getElementById('customItemModal'));
const debtModal = new bootstrap.Modal(document.getElementById('debtModal'));
const session = { cashier: null, kassa: null, shift: null };
function playBeep() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { console.error("Web Audio API is not supported"); return; }
}
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.15);
oscillator.start(audioCtx.currentTime);
oscillator.stop(audioCtx.currentTime + 0.15);
}
function parseLocaleNumber(stringNumber) {
return parseFloat(String(stringNumber).replace(/\\s/g, '').replace(',', '.')) || 0;
}
const updateCartView = () => {
cartItemsEl.innerHTML = '';
let subtotal = 0;
if (Object.keys(cart).length === 0) {
cartItemsEl.innerHTML = '<p class="text-center text-muted">Корзина пуста</p>';
}
for (const id in cart) {
const item = cart[id];
const itemTotal = parseLocaleNumber(item.price) * item.quantity;
subtotal += itemTotal;
cartItemsEl.innerHTML += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-0 small">${item.productName} ${item.variantName ? '('+item.variantName+')' : ''}</h6>
<small>${item.price} ₸</small>
</div>
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-outline-secondary cart-qty-btn" data-id="${id}" data-op="-1">-</button>
<input type="number" class="form-control form-control-sm text-center mx-1 cart-qty-input" data-id="${id}" value="${item.quantity}" style="width: 60px;" min="1">
<button class="btn btn-sm btn-outline-secondary cart-qty-btn" data-id="${id}" data-op="1">+</button>
</div>
</div>
</div>`;
}
let summaryHtml = '';
const totalDiscount = parseLocaleNumber(totalDiscountInput.value);
const loaderFee = loaderCheck.checked ? 1000 : 0;
let commissionFee = 0;
if(commissionCheck.checked){
commissionFee = subtotal * 0.0095;
summaryHtml += `<div class="d-flex justify-content-between small text-muted"><span>Комиссия 0.95%:</span><span>${commissionFee.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2})} ₸</span></div>`;
}
if(loaderCheck.checked){
summaryHtml += `<div class="d-flex justify-content-between small text-muted"><span>Грузчик:</span><span>1 000,00 ₸</span></div>`;
}
if(summaryHtml) {
summaryHtml = `<div class="d-flex justify-content-between small text-muted"><span>Сумма по товарам:</span><span>${subtotal.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2})} ₸</span></div>` + summaryHtml;
}
cartSummaryEl.innerHTML = summaryHtml;
const total = subtotal - totalDiscount + loaderFee + commissionFee;
cartTotalEl.textContent = total.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' ₸';
};
const addToCart = (product, variant) => {
const itemsPerPack = variant.items_per_pack || 1;
if (cart[variant.id]) {
cart[variant.id].quantity += itemsPerPack;
} else {
cart[variant.id] = {
productId: product.id,
productName: product.name,
variantName: variant.option_value,
price: String(variant.price).replace('.',','),
quantity: itemsPerPack,
items_per_pack: itemsPerPack
};
}
playBeep();
updateCartView();
};
const handleProductSelection = (product) => {
if (!product.variants || product.variants.length === 0) {
alert("У этого товара нет доступных вариантов.");
return;
}
if (product.variants.length === 1) {
addToCart(product, product.variants[0]);
} else {
document.getElementById('variantSelectModalTitle').textContent = `Выберите вариант для "${product.name}"`;
const variantList = document.getElementById('variant-list');
variantList.innerHTML = '';
product.variants.forEach(variant => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
const imageUrl = variant.image_urls && variant.image_urls.length > 0 ? `<img src="${variant.image_urls[0]}" class="img-thumbnail me-3" style="width: 50px; height: 50px; object-fit: cover;">` : '<div style="width: 50px; height: 50px;" class="me-3"></div>';
btn.innerHTML = `
<div class="d-flex align-items-center">
${imageUrl}
<div>${variant.option_value} - <strong>${parseFloat(variant.price).toLocaleString('ru-RU', {minimumFractionDigits: 2})} ₸</strong></div>
</div>
<span class="badge bg-secondary">Остаток: ${variant.stock}</span>`;
btn.addEventListener('click', () => {
addToCart(product, variant);
variantSelectModal.hide();
});
variantList.appendChild(btn);
});
variantSelectModal.show();
}
};
const fetchAndHandleProduct = (barcode) => {
fetch(`/api/product_by_barcode/${barcode}`)
.then(res => res.json())
.then(data => {
if (data.success) handleProductSelection(data.product);
else alert(data.message);
});
}
productGrid?.addEventListener('click', e => {
const card = e.target.closest('.product-card');
if (card) fetchAndHandleProduct(card.dataset.barcode);
});
const updateCartItemQuantity = (id, newQuantity) => {
if (cart[id]) {
const qty = parseInt(newQuantity, 10);
if (!isNaN(qty) && qty > 0) {
cart[id].quantity = qty;
} else {
delete cart[id];
}
updateCartView();
}
};
cartItemsEl?.addEventListener('click', e => {
if (e.target.classList.contains('cart-qty-btn')) {
const id = e.target.dataset.id;
const op = parseInt(e.target.dataset.op);
if (cart[id]) {
const newQuantity = cart[id].quantity + op;
updateCartItemQuantity(id, newQuantity);
}
}
});
cartItemsEl?.addEventListener('change', e => {
if (e.target.classList.contains('cart-qty-input')) {
updateCartItemQuantity(e.target.dataset.id, e.target.value);
}
});
document.getElementById('clear-cart-btn')?.addEventListener('click', () => {
for(const id in cart) delete cart[id];
totalDiscountInput.value = '0';
updateCartView();
});
document.getElementById('product-search')?.addEventListener('input', e => {
const term = e.target.value.toLowerCase();
document.querySelectorAll('#product-accordion .product-card').forEach(card => {
const productName = card.querySelector('.card-title').textContent.toLowerCase();
const barcode = card.dataset.barcode.toLowerCase();
card.style.display = (productName.includes(term) || barcode.includes(term)) ? '' : 'none';
});
document.querySelectorAll('#product-accordion .accordion-item').forEach(accordionItem => {
const collapseElement = accordionItem.querySelector('.accordion-collapse');
const matchingCards = accordionItem.querySelectorAll('.product-card:not([style*="display: none"])');
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(collapseElement, { toggle: false });
if (term === '') bsCollapse.hide();
else matchingCards.length > 0 ? bsCollapse.show() : bsCollapse.hide();
});
});
const completeSale = (paymentMethod, debtInfo = {}) => {
if (!session.shift || !session.cashier || !session.kassa) {
alert('Смена не активна. Начните смену, чтобы проводить продажи.');
return;
}
if (Object.keys(cart).length === 0) {
alert('Корзина пуста!');
return;
}
fetch('/api/complete_sale', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
cart: cart, userId: session.cashier.id, kassaId: session.kassa.id,
shiftId: session.shift.id, paymentMethod: paymentMethod,
addCommission: commissionCheck.checked, addLoaderFee: loaderCheck.checked,
debtInfo: debtInfo, totalDiscount: totalDiscountInput.value
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
for(const id in cart) delete cart[id];
commissionCheck.checked = false;
loaderCheck.checked = false;
totalDiscountInput.value = '0';
updateCartView();
document.getElementById('receipt-url').value = data.receiptUrl;
document.getElementById('view-receipt-btn').href = data.receiptUrl;
receiptModal.show();
} else {
alert(`Ошибка: ${data.message}`);
}
})
.catch(err => alert(`Сетевая ошибка: ${err}`));
};
document.getElementById('pay-cash-btn')?.addEventListener('click', () => completeSale('cash'));
document.getElementById('pay-card-btn')?.addEventListener('click', () => completeSale('card'));
document.getElementById('pay-debt-btn')?.addEventListener('click', () => debtModal.show());
document.getElementById('confirm-debt-btn')?.addEventListener('click', () => {
const name = document.getElementById('debtor-name').value.trim();
const dueDate = document.getElementById('debt-due-date').value.trim();
if(!name) { alert("Имя и фамилия должника обязательны."); return; }
debtModal.hide();
completeSale('debt', { name, dueDate });
document.getElementById('debtor-name').value = '';
document.getElementById('debt-due-date').value = '';
});
commissionCheck.addEventListener('change', updateCartView);
loaderCheck.addEventListener('change', updateCartView);
totalDiscountInput.addEventListener('input', updateCartView);
document.getElementById('send-whatsapp-btn').addEventListener('click', () => {
const phone = document.getElementById('whatsapp-phone').value.replace(/\\D/g, '');
const receiptUrl = document.getElementById('receipt-url').value;
if (phone && receiptUrl) {
const fullPhone = '7' + phone;
const message = encodeURIComponent(`Ваша накладная: ${receiptUrl}`);
window.open(`https://wa.me/${fullPhone}?text=${message}`, '_blank');
} else {
alert('Введите номер телефона.');
}
});
const html5QrCode = new Html5Qrcode("reader");
const scannerStatusEl = document.getElementById('scanner-status');
const onScanSuccess = (decodedText, decodedResult) => {
if (isScannerPaused) return;
isScannerPaused = true;
scannerStatusEl.textContent = 'Пауза...';
scannerStatusEl.style.display = 'block';
if (html5QrCode.getState() === 2) html5QrCode.pause();
fetchAndHandleProduct(decodedText);
setTimeout(() => {
isScannerPaused = false;
scannerStatusEl.style.display = 'none';
if (html5QrCode.getState() === 2) html5QrCode.resume();
}, 1000);
};
const startScanner = () => {
document.getElementById('scanner-container').style.display = 'block';
html5QrCode.start({ facingMode: "environment" }, { fps: 15, qrbox: { width: 300, height: 150 } }, onScanSuccess)
.catch(err => console.error("Scanner start error:", err));
};
const stopScanner = () => {
if (html5QrCode.isScanning) {
html5QrCode.stop().then(() => {
document.getElementById('scanner-container').style.display = 'none';
}).catch(err => console.error("Failed to stop scanner", err));
} else {
document.getElementById('scanner-container').style.display = 'none';
}
};
document.getElementById('scan-btn')?.addEventListener('click', startScanner);
document.getElementById('stop-scan-btn')?.addEventListener('click', stopScanner);
const updateSessionUI = () => {
const sessionInfoEl = document.getElementById('session-info');
const shiftControlsEl = document.getElementById('shift-controls');
if (!sessionInfoEl || !shiftControlsEl) return;
if (session.shift) {
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong> | Касса: <strong>${session.kassa.name}</strong>`;
shiftControlsEl.innerHTML = '<button id="end-shift-btn" class="btn btn-danger btn-sm">Закончить смену</button>';
document.getElementById('end-shift-btn').addEventListener('click', handleEndShift);
} else if (session.cashier) {
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong>. Смена не начата.`;
shiftControlsEl.innerHTML = '<button id="start-shift-btn" class="btn btn-success btn-sm">Начать смену</button>';
document.getElementById('start-shift-btn').addEventListener('click', () => {
document.getElementById('start-shift-cashier-name').textContent = session.cashier.name;
startShiftModal.show();
});
} else {
sessionInfoEl.innerHTML = 'Нет активного кассира';
shiftControlsEl.innerHTML = '';
}
};
const handleLogin = (pin) => {
fetch('/api/verify_pin', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin: pin})
})
.then(res => res.json())
.then(data => {
if (data.success) {
session.cashier = data.user;
localStorage.setItem('current_cashier', JSON.stringify(data.user));
cashierLoginModal.hide();
updateSessionUI();
document.getElementById('start-shift-cashier-name').textContent = session.cashier.name;
startShiftModal.show();
} else {
document.getElementById('pin-error').textContent = data.message;
}
});
};
const handleLogout = () => {
session.cashier = null; session.kassa = null; session.shift = null;
localStorage.removeItem('current_cashier');
localStorage.removeItem('current_kassa');
localStorage.removeItem('current_shift');
startShiftModal.hide();
updateSessionUI();
cashierLoginModal.show();
};
const handleStartShift = () => {
const kassaSelect = document.getElementById('kassa-select-modal');
const kassaId = kassaSelect.value;
if (!kassaId) { alert('Выберите кассу'); return; }
fetch('/api/shift/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({userId: session.cashier.id, kassaId: kassaId})
})
.then(res => res.json())
.then(data => {
if (data.success) {
session.kassa = {id: data.shift.kassa_id, name: data.shift.kassa_name};
session.shift = data.shift;
localStorage.setItem('current_kassa', JSON.stringify(session.kassa));
localStorage.setItem('current_shift', JSON.stringify(session.shift));
startShiftModal.hide();
updateSessionUI();
} else {
alert(`Ошибка: ${data.message}`);
}
});
};
const handleEndShift = () => {
if (!confirm('Вы уверены, что хотите закончить смену?')) return;
fetch('/api/shift/end', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({shiftId: session.shift.id})
})
.then(res => res.json())
.then(data => {
if (data.success) {
session.kassa = null; session.shift = null;
localStorage.removeItem('current_kassa');
localStorage.removeItem('current_shift');
updateSessionUI();
alert(data.message);
} else {
alert(`Ошибка: ${data.message}`);
}
});
};
const initializeSession = () => {
const storedCashier = localStorage.getItem('current_cashier');
const storedKassa = localStorage.getItem('current_kassa');
const storedShift = localStorage.getItem('current_shift');
if (storedCashier) session.cashier = JSON.parse(storedCashier);
if (storedKassa) session.kassa = JSON.parse(storedKassa);
if (storedShift) session.shift = JSON.parse(storedShift);
updateSessionUI();
if (document.getElementById('cashierLoginModal')) {
if (!session.cashier) cashierLoginModal.show();
else if (!session.shift) {
document.getElementById('start-shift-cashier-name').textContent = session.cashier.name;
startShiftModal.show();
}
}
};
document.getElementById('pin-submit-btn')?.addEventListener('click', () => handleLogin(document.getElementById('cashier-pin-input').value));
document.getElementById('cashier-pin-input')?.addEventListener('keypress', (e) => { if(e.key === 'Enter') handleLogin(e.target.value); });
document.getElementById('start-shift-confirm-btn')?.addEventListener('click', handleStartShift);
document.getElementById('logout-btn')?.addEventListener('click', handleLogout);
document.getElementById('custom-item-btn')?.addEventListener('click', () => customItemModal.show());
document.getElementById('custom-item-form')?.addEventListener('submit', (e) => {
e.preventDefault();
const name = document.getElementById('custom-item-name').value || 'Товар без штрихкода';
const price = document.getElementById('custom-item-price').value;
const qty = parseInt(document.getElementById('custom-item-qty').value);
if (parseLocaleNumber(price) > 0 && qty > 0) {
const customId = 'custom_' + Date.now();
cart[customId] = {
productName: name, price: String(price).replace('.',','),
quantity: qty, isCustom: true
};
updateCartView();
customItemModal.hide();
e.target.reset();
} else {
alert('Введите корректную цену и количество.');
}
});
if (cartItemsEl) updateCartView();
initializeSession();
});
</script>
"""
INVENTORY_CONTENT = """
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Единиц товара на складе</h6>
<h4 class="card-title">{{ inventory_summary.total_units }} шт.</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Сумма по себестоимости</h6>
<h4 class="card-title">{{ format_currency_py(inventory_summary.total_cost_value) }} ₸</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Потенциальная прибыль</h6>
<h4 class="card-title text-success">{{ format_currency_py(inventory_summary.potential_profit) }} ₸</h4>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between mb-3">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProductModal"><i class="fas fa-plus me-2"></i>Добавить товар</button>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#stockInModal"><i class="fas fa-truck-loading me-2"></i>Оприходовать</button>
</div>
<input type="text" id="inventory-search" class="form-control mb-3" placeholder="Поиск по названию, варианту или штрих-коду...">
<div class="accordion" id="inventoryAccordion">
{% for p in inventory %}
<div class="accordion-item">
<h2 class="accordion-header" id="heading-{{ p.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ p.id }}">
<strong>{{ p.name }}</strong>&nbsp;<small class="text-muted"> ({{ p.barcode }})</small>
</button>
</h2>
<div id="collapse-{{ p.id }}" class="accordion-collapse collapse" data-bs-parent="#inventoryAccordion">
<div class="accordion-body">
<div class="d-flex justify-content-end mb-2">
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editProductModal-{{ p.id }}"><i class="fas fa-edit me-1"></i>Редактировать товар</button>
<form action="{{ url_for('delete_product', product_id=p.id) }}" method="POST" class="d-inline ms-2" onsubmit="return confirm('Удалить товар?');">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
</form>
</div>
<table class="table table-sm table-bordered">
<thead><tr><th>Фото</th><th>Вариант</th><th>Цена</th><th>Себест.</th><th>Остаток</th><th>Цвета</th></tr></thead>
<tbody>
{% for v in p.variants %}
<tr>
<td>
<img src="{{ (v.image_urls|first) if v.image_urls else url_for('static', filename='placeholder.png') }}" class="img-thumbnail" style="width: 40px; height: 40px; object-fit: cover;">
</td>
<td>{{ v.option_value }}</td>
<td>{{ format_currency_py(v.price) }} ₸</td>
<td>{{ format_currency_py(v.cost_price) }} ₸</td>
<td>{{ v.stock }}</td>
<td>
{% for color in v.get('colors', []) %}
<span class="badge bg-secondary">{{ color }}</span>
{% endfor %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted">Нет вариантов</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="modal fade" id="addProductModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Новый товар</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form action="{{ url_for('inventory_management') }}" method="POST">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" class="form-control" required></div>
<div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label>
<div class="input-group"><input type="text" name="barcode" class="form-control barcode-input" required><button type="button" class="btn btn-outline-secondary scan-modal-btn"><i class="fas fa-barcode"></i></button></div>
</div>
</div>
<div id="modal-scanner-add" class="mb-2" style="display:none;"></div>
<hr>
<h6>Варианты товара</h6>
<div id="variants-container-add"></div>
<button type="button" class="btn btn-sm btn-outline-success mt-2" id="add-variant-btn-add">Добавить вариант</button>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
</form>
</div>
</div>
</div>
{% for p in inventory %}
<div class="modal fade" id="editProductModal-{{ p.id }}" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Редактировать товар</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form action="{{ url_for('edit_product', product_id=p.id) }}" method="POST">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" class="form-control" value="{{ p.name }}" required></div>
<div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label><input type="text" name="barcode" class="form-control" value="{{ p.barcode }}" required></div>
</div>
<hr>
<h6>Варианты товара</h6>
<div id="variants-container-edit-{{ p.id }}">
{% for v in p.variants %}
<div class="row g-2 mb-3 align-items-start variant-row border-bottom pb-3">
<input type="hidden" name="variant_id[]" value="{{ v.id }}">
<div class="col-md-6">
<div class="row g-2">
<div class="col-md-6"><label class="form-label small">Название варианта</label><input type="text" name="variant_name[]" class="form-control" value="{{ v.option_value }}" required></div>
<div class="col-md-6"><label class="form-label small">Остаток</label><input type="number" name="variant_stock[]" class="form-control" value="{{ v.stock }}"></div>
<div class="col-md-6"><label class="form-label small">Цена</label><input type="text" name="variant_price[]" class="form-control" value="{{ v.price|string|replace('.', ',') }}" inputmode="decimal"></div>
<div class="col-md-6"><label class="form-label small">Себест.</label><input type="text" name="variant_cost_price[]" class="form-control" value="{{ v.cost_price|string|replace('.', ',') }}" inputmode="decimal"></div>
<div class="col-md-12"><label class="form-label small">В пачке</label><input type="number" name="variant_items_per_pack[]" class="form-control" value="{{ v.get('items_per_pack', 1) }}" min="1"></div>
</div>
<div class="mt-2">
<label class="form-label small">Цвета</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control add-color-input" placeholder="Введите цвет">
<button class="btn btn-outline-secondary add-color-btn" type="button">Добавить</button>
</div>
<div class="d-flex flex-wrap gap-1 mt-2 colors-pills-container">
{% for color in v.get('colors', []) %}
<span class="badge bg-secondary d-flex align-items-center">{{ color }}<button type="button" class="btn-close btn-close-white ms-1 btn-sm remove-color-btn"></button></span>
{% endfor %}
</div>
<input type="hidden" class="variant-colors-input" name="variant_colors[]" value="{{ v.get('colors', [])|tojson }}">
</div>
</div>
<div class="col-md-6">
<label class="form-label small">Фотографии</label>
<div class="variant-image-previews d-flex flex-wrap gap-2 mb-2">
{% for img_url in v.image_urls %}
<div class="img-preview-container position-relative">
<img src="{{ img_url }}" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 remove-image-btn" style="line-height: 1; padding: .1rem .3rem;"><i class="fas fa-times"></i></button>
</div>
{% endfor %}
</div>
<input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*" multiple>
<input type="hidden" class="variant-image-urls-input" name="variant_image_urls[]" value="{{ v.image_urls|tojson }}">
</div>
<div class="col-12 text-end mt-2"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times me-1"></i>Удалить вариант</button></div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-outline-success mt-2 add-variant-btn-edit" data-target-container="variants-container-edit-{{ p.id }}">Добавить вариант</button>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
</form>
</div>
</div>
</div>
{% endfor %}
<div class="modal fade" id="stockInModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Оприходование товара</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form action="{{ url_for('stock_in') }}" method="POST">
<div class="modal-body">
<div class="mb-3"><label for="stockin-product" class="form-label">Товар</label><select id="stockin-product" name="product_id" class="form-select" required><option value="">-- Выберите товар --</option>{% for p in inventory %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}</select></div>
<div class="mb-3"><label for="stockin-variant" class="form-label">Вариант</label><select id="stockin-variant" name="variant_id" class="form-select" required disabled><option value="">-- Сначала выберите товар --</option></select></div>
<div class="row">
<div class="col-md-6 mb-3"><label for="stockin-quantity" class="form-label">Количество</label><input type="number" id="stockin-quantity" name="quantity" class="form-control" required min="1"></div>
<div class="col-md-6 mb-3"><label for="stockin-cost" class="form-label">Себестоимость (за ед.)</label><input type="text" id="stockin-cost" name="cost_price" class="form-control" inputmode="decimal" placeholder="Необязательно"></div>
</div>
<div class="mb-3"><label for="stockin-delivery" class="form-label">Стоимость доставки (общая)</label><input type="text" id="stockin-delivery" name="delivery_cost" class="form-control" inputmode="decimal" placeholder="0"><div class="form-text">Эта сумма будет добавлена в расходы как "Дорога" и учтена в себестоимости.</div></div>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Оприходовать</button></div>
</form>
</div>
</div>
</div>
"""
INVENTORY_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', () => {
let currentScanner = null;
let currentScannerContainer = null;
document.querySelectorAll('.scan-modal-btn').forEach(btn => {
btn.addEventListener('click', e => {
const form = e.target.closest('form');
const scannerContainer = form.querySelector('[id^="modal-scanner-"]');
const barcodeInput = form.querySelector('.barcode-input');
if (currentScanner) {
try {
currentScanner.stop();
} catch (e) {}
currentScanner = null;
if (currentScannerContainer) currentScannerContainer.style.display = 'none';
return;
}
scannerContainer.style.display = 'block';
currentScannerContainer = scannerContainer;
const scannerId = scannerContainer.id + '-reader';
if (!document.getElementById(scannerId)) scannerContainer.innerHTML = `<div id="${scannerId}" style="width: 100%;"></div>`;
const html5QrCode = new Html5Qrcode(scannerId);
currentScanner = html5QrCode;
const onScanSuccess = (decodedText, decodedResult) => {
barcodeInput.value = decodedText;
try {
html5QrCode.stop();
} catch (e) {}
currentScanner = null;
scannerContainer.style.display = 'none';
};
html5QrCode.start({
facingMode: "environment"
}, {
fps: 10,
qrbox: {
width: 250,
height: 250
}
}, onScanSuccess);
});
});
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('hidden.bs.modal', () => {
if (currentScanner) {
try {
currentScanner.stop();
} catch (e) {}
currentScanner = null;
}
if (currentScannerContainer) {
currentScannerContainer.style.display = 'none';
currentScannerContainer.innerHTML = '';
}
});
});
const updateImageUrlsInput = c => {
const urls = Array.from(c.querySelectorAll('.img-preview-container img')).map(p => p.getAttribute('src'));
c.closest('.variant-row').querySelector('.variant-image-urls-input').value = JSON.stringify(urls);
};
const createImagePreview = (url, c) => {
const p = document.createElement('div');
p.className = 'img-preview-container position-relative';
p.innerHTML = `<img src="${url}" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;"><button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 remove-image-btn" style="line-height: 1; padding: .1rem .3rem;"><i class="fas fa-times"></i></button>`;
c.appendChild(p);
};
const handleImageUpload = input => {
const p = input.closest('.variant-row').querySelector('.variant-image-previews');
const existingImagesCount = p.querySelectorAll('.img-preview-container').length;
if (!input.files.length) return;
if (existingImagesCount + input.files.length > 10) {
alert('Можно загрузить не более 10 изображений для одного варианта.');
input.value = '';
return;
}
const formData = new FormData();
Array.from(input.files).forEach(file => {
formData.append('image', file);
});
fetch("{{ url_for('upload_image') }}", {
method: 'POST',
body: formData
}).then(r => r.json()).then(d => {
if (d.success && d.urls) {
d.urls.forEach(url => {
createImagePreview(url, p);
});
updateImageUrlsInput(p);
} else {
alert('Ошибка загрузки: ' + d.message);
}
}).catch(e => {
console.error(e);
alert('Сетевая ошибка при загрузке изображения.');
}).finally(() => {
input.value = '';
});
};
const updateColorsInput = (container) => {
const pills = container.querySelectorAll('.badge');
const colors = Array.from(pills).map(p => p.textContent.trim());
const hiddenInput = container.closest('.variant-row').querySelector('.variant-colors-input');
hiddenInput.value = JSON.stringify(colors);
};
const createColorPill = (color, container) => {
const pill = document.createElement('span');
pill.className = 'badge bg-secondary d-flex align-items-center';
pill.innerHTML = `${color}<button type="button" class="btn-close btn-close-white ms-1 btn-sm remove-color-btn"></button>`;
container.appendChild(pill);
};
const createVariantRowHTML = () => `
<div class="row g-2 mb-3 align-items-start variant-row border-bottom pb-3">
<input type="hidden" name="variant_id[]" value="">
<div class="col-md-6">
<div class="row g-2">
<div class="col-md-6"><label class="form-label small">Название варианта</label><input type="text" name="variant_name[]" class="form-control" required></div>
<div class="col-md-6"><label class="form-label small">Остаток</label><input type="number" name="variant_stock[]" class="form-control" value="0"></div>
<div class="col-md-6"><label class="form-label small">Цена</label><input type="text" name="variant_price[]" class="form-control" inputmode="decimal"></div>
<div class="col-md-6"><label class="form-label small">Себест.</label><input type="text" name="variant_cost_price[]" class="form-control" inputmode="decimal"></div>
<div class="col-md-12"><label class="form-label small">В пачке</label><input type="number" name="variant_items_per_pack[]" class="form-control" value="1" min="1"></div>
</div>
<div class="mt-2">
<label class="form-label small">Цвета</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control add-color-input" placeholder="Введите цвет">
<button class="btn btn-outline-secondary add-color-btn" type="button">Добавить</button>
</div>
<div class="d-flex flex-wrap gap-1 mt-2 colors-pills-container"></div>
<input type="hidden" class="variant-colors-input" name="variant_colors[]" value="[]">
</div>
</div>
<div class="col-md-6">
<label class="form-label small">Фотографии</label>
<div class="variant-image-previews d-flex flex-wrap gap-2 mb-2"></div>
<input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*" multiple>
<input type="hidden" class="variant-image-urls-input" name="variant_image_urls[]" value="[]">
</div>
<div class="col-12 text-end mt-2"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times me-1"></i>Удалить вариант</button></div>
</div>`;
const createVariantRow = () => {
const d = document.createElement('div');
d.innerHTML = createVariantRowHTML();
return d.firstElementChild;
};
document.body.addEventListener('change', e => {
if (e.target.classList.contains('variant-image-upload')) handleImageUpload(e.target);
});
document.body.addEventListener('click', e => {
if (e.target.id === 'add-variant-btn-add') {
document.getElementById('variants-container-add').appendChild(createVariantRow());
return;
}
const addVariantBtnEdit = e.target.closest('.add-variant-btn-edit');
if (addVariantBtnEdit) {
document.getElementById(addVariantBtnEdit.dataset.targetContainer).appendChild(createVariantRow());
return;
}
const addColorBtn = e.target.closest('.add-color-btn');
if (addColorBtn) {
const input = addColorBtn.previousElementSibling;
const color = input.value.trim();
if (color) {
const container = addColorBtn.closest('.variant-row').querySelector('.colors-pills-container');
createColorPill(color, container);
updateColorsInput(container);
input.value = '';
}
return;
}
if (e.target.closest('.remove-variant-btn')) {
e.target.closest('.variant-row').remove();
return;
}
if (e.target.closest('.remove-image-btn')) {
const c = e.target.closest('.img-preview-container');
const w = c.parentElement;
c.remove();
updateImageUrlsInput(w);
return;
}
if (e.target.closest('.remove-color-btn')) {
const c = e.target.closest('.badge');
const w = c.parentElement;
c.remove();
updateColorsInput(w);
return;
}
});
document.getElementById('addProductModal').addEventListener('shown.bs.modal', () => {
const c = document.getElementById('variants-container-add');
if (c.children.length === 0) c.appendChild(createVariantRow());
});
const inventoryData = JSON.parse('{{ inventory|tojson|safe }}');
const productSelect = document.getElementById('stockin-product');
const variantSelect = document.getElementById('stockin-variant');
productSelect.addEventListener('change', () => {
const pId = productSelect.value;
variantSelect.innerHTML = '<option value="">-- Выберите вариант --</option>';
variantSelect.disabled = true;
if (pId) {
const p = inventoryData.find(p => p.id === pId);
if (p && p.variants) {
p.variants.forEach(v => {
const o = document.createElement('option');
o.value = v.id;
o.textContent = v.option_value;
variantSelect.appendChild(o);
});
variantSelect.disabled = false;
}
}
});
document.getElementById('inventory-search').addEventListener('input', e => {
const term = e.target.value.toLowerCase();
document.querySelectorAll('#inventoryAccordion .accordion-item').forEach(item => {
const pName = item.querySelector('.accordion-button strong').textContent.toLowerCase();
const barcode = item.querySelector('.accordion-button small').textContent.toLowerCase();
const vMatch = Array.from(item.querySelectorAll('.accordion-body table tbody tr td:nth-child(2)')).some(td => td.textContent.toLowerCase().includes(term));
item.style.display = (pName.includes(term) || barcode.includes(term) || vMatch) ? '' : 'none';
});
});
});
</script>
"""
TRANSACTIONS_CONTENT = """
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-2 align-items-end">
<div class="col-auto"><label for="date-filter" class="form-label">Дата</label><input type="date" id="date-filter" name="date" value="{{ selected_date }}" class="form-control"></div>
<div class="col-auto"><label for="kassa-filter" class="form-label">Касса</label><select id="kassa-filter" name="kassa" class="form-select"><option value="">Все кассы</option>{% for k in kassas %}<option value="{{ k.id }}" {% if k.id == selected_kassa_id %}selected{% endif %}>{{ k.name }}</option>{% endfor %}</select></div>
<div class="col-auto"><button type="submit" class="btn btn-primary">Показать</button></div>
</form>
<hr>
{% set selected_kassa = kassas|selectattr('id', 'equalto', selected_kassa_id)|first %}
<div class="d-flex justify-content-between align-items-center flex-wrap">
<h4 class="mb-0">Общая выручка за {{ selected_date }}{% if selected_kassa %} (Касса: {{ selected_kassa.name }}){% endif %}: <span class="text-success">{{ format_currency_py(total_sales) }} ₸</span></h4>
<h5 class="mb-0 text-muted">Продано пар: <span class="fw-bold text-dark">{{ total_quantity_sold }}</span></h5>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Кассир</th><th>Касса</th><th>Сумма</th><th>Статус</th><th>Позиции</th><th></th></tr></thead>
<tbody>
{% for t in transactions %}
<tr class="{% if t.type == 'return' %}table-danger{% endif %}">
<td><a href="{{ url_for('view_receipt', transaction_id=t.id) }}" target="_blank"><small class="text-muted">{{ t.id[:8] }}</small></a></td>
<td>{{ t.timestamp[11:16] }}</td>
<td>
{% if t.type == 'sale' %}
{% if t.payment_method == 'cash' %}<span class="badge bg-primary">Наличные</span>
{% elif t.payment_method == 'card' %}<span class="badge bg-info">Карта</span>
{% elif t.payment_method == 'debt' %}<span class="badge bg-warning text-dark">В долг</span>
{% endif %}
{% elif t.type == 'return' %}<span class="badge bg-danger">Возврат</span>
{% elif t.type == 'online_sale' %}<span class="badge bg-success">Онлайн</span>
{% endif %}
</td>
<td>{{ t.user_name }}</td><td>{{ t.kassa_name }}</td>
<td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
<td>
{% if t.status == 'completed' %}<span class="badge bg-success">Завершено</span>
{% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращено</span>
{% elif t.status == 'partially_returned' %}<span class="badge bg-warning text-dark">Част. возврат</span>
{% else %}<span class="badge bg-secondary">{{t.status}}</span>
{% endif %}
</td>
<td>
<ul class="list-unstyled mb-0 small">
{% for item in t['items'] %}<li>{{ item.name }} ({{ item.quantity }}x{{ format_currency_py(item.price_at_sale) }})</li>{% endfor %}
</ul>
</td>
<td>
{% if session.admin_logged_in %}
{% if t.type == 'sale' %}
<button class="btn btn-xs btn-outline-secondary py-0 px-1" data-bs-toggle="modal" data-bs-target="#editTransactionModal" data-transaction-id="{{t.id}}" data-transaction-items="{{t['items']|tojson}}"><i class="fas fa-pencil-alt"></i></button>
{% endif %}
<form action="{{ url_for('delete_transaction', transaction_id=t.id) }}" method="POST" class="d-inline ms-1" onsubmit="return confirm('Удалить эту транзакцию? Действие необратимо.');">
<button type="submit" class="btn btn-xs btn-outline-danger py-0 px-1"><i class="fas fa-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center">Нет транзакций за выбранную дату.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="editTransactionModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Редактировать транзакцию</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<p>ID: <strong id="edit-trans-id"></strong></p>
<form id="edit-trans-form">
<div id="edit-trans-items-container" class="table-responsive"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" id="save-trans-btn" class="btn btn-primary">Сохранить изменения</button>
</div>
</div>
</div>
</div>
"""
TRANSACTIONS_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', () => {
const editModal = new bootstrap.Modal(document.getElementById('editTransactionModal'));
const editModalEl = document.getElementById('editTransactionModal');
let currentTransactionId = null;
editModalEl.addEventListener('show.bs.modal', event => {
const button = event.relatedTarget;
currentTransactionId = button.dataset.transactionId;
const items = JSON.parse(button.dataset.transactionItems);
document.getElementById('edit-trans-id').textContent = currentTransactionId.substring(0, 8);
const container = document.getElementById('edit-trans-items-container');
let tableHtml = `<table class="table table-sm"><thead><tr><th>Товар</th><th>Цена</th></tr></thead><tbody>`;
items.forEach(item => {
const itemId = item.variant_id || item.product_id;
tableHtml += `
<tr data-item-id="${itemId}">
<td>${item.name} (${item.quantity} шт.)</td>
<td><input type="text" class="form-control form-control-sm" name="price" value="${String(item.price_at_sale).replace('.',',')}" inputmode="decimal"></td>
</tr>`;
});
tableHtml += `</tbody></table>`;
container.innerHTML = tableHtml;
});
document.getElementById('save-trans-btn').addEventListener('click', () => {
const form = document.getElementById('edit-trans-form');
const items_update = [];
form.querySelectorAll('tbody tr').forEach(row => {
items_update.push({
id: row.dataset.itemId,
price: row.querySelector('input[name="price"]').value
});
});
fetch(`/admin/transaction/edit/${currentTransactionId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ items: items_update })
})
.then(res => res.json())
.then(data => {
if (data.success) {
editModal.hide();
window.location.reload();
} else {
alert('Ошибка: ' + data.message);
}
})
.catch(err => {
console.error(err);
alert('Сетевая ошибка.');
});
});
});
</script>
"""
REPORTS_CONTENT = """
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="{{ url_for('reports') }}">
<div class="row g-2 align-items-end">
<div class="col-md-4"><label for="start_date" class="form-label">Начало периода</label><input type="date" id="start_date" name="start_date" value="{{ start_date }}" class="form-control"></div>
<div class="col-md-4"><label for="end_date" class="form-label">Конец периода</label><input type="date" id="end_date" name="end_date" value="{{ end_date }}" class="form-control"></div>
<div class="col-md-4"><button type="submit" class="btn btn-primary w-100">Сформировать отчет</button></div>
</div>
</form>
</div>
</div>
<div class="row">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Сводный отчет за период</h5></div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-chart-bar me-2 text-primary"></i>Выручка (за вычетом возвратов)</span> <strong>{{ format_currency_py(stats.total_revenue) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-globe me-2 text-success"></i>в т.ч. доходы с онлайн каталога</span> <strong>{{ format_currency_py(stats.online_revenue) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-undo me-2 text-danger"></i>Возвраты ({{ stats.total_returns_count }} шт.)</span> <strong class="text-danger">-{{ format_currency_py(stats.total_returns_amount) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-cogs me-2 text-secondary"></i>Себестоимость проданных товаров</span> <strong>{{ format_currency_py(stats.total_cogs) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-piggy-bank me-2 text-info"></i>Валовая прибыль</span> <strong class="text-info">{{ format_currency_py(stats.gross_profit) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-receipt me-2 text-warning"></i>Расходы (операционные)</span> <strong class="text-warning">-{{ format_currency_py(stats.total_expenses) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-user-tie me-2" style="color: orange;"></i>Расходы (личные)</span> <strong class="text-warning">-{{ format_currency_py(stats.total_personal_expenses) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-users-cog me-2 text-danger"></i>Расходы (зарплаты)</span> <strong class="text-danger">-{{ format_currency_py(stats.total_salary_expenses) }} ₸</strong></li>
<li class="list-group-item d-flex justify-content-between fs-5"><span><i class="fas fa-check-circle me-2 text-success"></i>Чистая прибыль</span> <strong class="text-success">{{ format_currency_py(stats.net_profit) }} ₸</strong></li>
</ul>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Расчеты с кассирами за период</h5></div>
<div class="card-body p-0">
<div class="table-responsive"><table class="table table-sm mb-0">
<thead><tr><th>Кассир</th><th class="text-end">К выплате</th></tr></thead>
<tbody>
{% for name, payout in stats.cashier_payouts %}
<tr><td>{{ name }}</td><td class="text-end">{{ format_currency_py(payout) }} ₸</td></tr>
{% else %}<tr><td colspan="2" class="text-center text-muted">Нет данных для расчета.</td></tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Продажи по кассирам</h5></div>
<div class="card-body p-0">
<div class="table-responsive"><table class="table table-hover mb-0">
<thead><tr><th>Кассир</th><th>Чеков</th><th class="text-end">Сумма</th></tr></thead>
<tbody>
{% for name, data in stats.sales_by_cashier %}
<tr><td>{{ name }}</td><td>{{ data.count }}</td><td class="text-end">{{ format_currency_py(data.total) }} ₸</td></tr>
{% else %}<tr><td colspan="3" class="text-center text-muted">Нет продаж за выбранный период.</td></tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Операционные расходы за период</h5></div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;"><table class="table table-sm mb-0">
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th></tr></thead>
<tbody>
{% for expense in expenses %}
<tr>
<td><small>{{ expense.timestamp[:10] }}</small></td>
<td>{{ expense.description }}</td>
<td class="text-end">{{ format_currency_py(expense.amount) }} ₸</td>
</tr>
{% else %}<tr><td colspan="3" class="text-center text-muted">Нет расходов за выбранный период.</td></tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Личные расходы за период</h5></div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;"><table class="table table-sm mb-0">
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th></tr></thead>
<tbody>
{% for expense in personal_expenses %}
<tr>
<td><small>{{ expense.timestamp[:10] }}</small></td>
<td>{{ expense.description }}</td>
<td class="text-end">{{ format_currency_py(expense.amount) }} ₸</td>
</tr>
{% else %}<tr><td colspan="3" class="text-center text-muted">Нет личных расходов за выбранный период.</td></tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
</div>
</div>
"""
REPORTS_SCRIPTS = ""
PRODUCT_ROI_CONTENT = """
<div class="card">
<div class="card-header"><h5 class="mb-0">Окупаемость по товарам</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Товар (вариант)</th>
<th class="text-end">Продано, шт</th>
<th class="text-end">Выручка</th>
<th class="text-end">Стоимость на складе</th>
<th class="text-end">Общие вложения</th>
<th class="text-end">Результат (Окупаемость)</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td><strong>{{ item.name }}</strong><br><small class="text-muted">{{ item.variant_name }}</small></td>
<td class="text-end">{{ item.total_qty_sold }}</td>
<td class="text-end">{{ format_currency_py(item.total_revenue) }} ₸</td>
<td class="text-end">{{ format_currency_py(item.inventory_value) }} ₸</td>
<td class="text-end text-secondary">{{ format_currency_py(item.total_investment) }} ₸</td>
<td class="text-end fw-bold
{% if item.payback > 0 %}payback-positive
{% elif item.payback < 0 %}payback-negative
{% else %}payback-zero{% endif %}">
{{ format_currency_py(item.payback) }} ₸
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center">Нет данных для анализа.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer small text-muted">
<i class="fas fa-info-circle me-1"></i>
<strong>Общие вложения</strong> = Себестоимость проданных товаров + Стоимость текущих остатков на складе.
<strong>Результат</strong> = Выручка - Общие вложения.
</div>
</div>
"""
ADMIN_CONTENT = """
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex gap-2">
<a href="{{ url_for('admin_shifts') }}" class="btn btn-info"><i class="fas fa-history me-2"></i>История смен</a>
</div>
</div>
<div class="col-12 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0">Настройки</h5></div>
<div class="card-body">
<form action="{{ url_for('save_settings') }}" method="POST">
<div class="mb-3">
<label for="whatsapp_number" class="form-label">WhatsApp номер для заказов</label>
<input type="text" class="form-control" id="whatsapp_number" name="whatsapp_number" value="{{ settings.get('whatsapp_number', '') }}" placeholder="77081234567">
<div class="form-text">Введите номер без +7, например 77081234567.</div>
</div>
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
</form>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0">Кассиры</h5><button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addUserModal"><i class="fas fa-plus"></i></button></div>
<div class="card-body">
<ul class="list-group">
{% for u in users %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>{{ u.name }} <br>
<small class="text-muted">
{% if u.payment_type == 'salary' %}Зарплата: {{ format_currency_py(u.payment_value) }} ₸/мес.
{% elif u.payment_type == 'percentage' %}Процент: {{ u.payment_value }}%
{% endif %}
</small>
</div>
<div>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editUserModal-{{ u.id }}"><i class="fas fa-edit"></i></button>
<form action="{{ url_for('manage_user') }}" method="POST" class="d-inline" onsubmit="return confirm('Удалить кассира?');"><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="{{ u.id }}"><button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button></form>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Кассы</h5></div>
<div class="card-body">
<form action="{{ url_for('manage_kassa') }}" method="POST" class="mb-3">
<input type="hidden" name="action" value="add">
<div class="input-group">
<input type="text" name="name" class="form-control" placeholder="Название кассы" required>
<input type="text" name="balance" class="form-control" placeholder="Начальный баланс" value="0" inputmode="decimal">
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
<ul class="list-group">
{% for k in kassas %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>{{ k.name }} <br><small class="fw-bold">{{ format_currency_py(k.balance) }} ₸</small></div>
<form action="{{ url_for('manage_kassa') }}" method="POST" onsubmit="return confirm('Удалить кассу?');"><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="{{ k.id }}"><button type="submit" class="btn btn-sm btn-danger"><i class="fas fa-trash"></i></button></form>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="col-12 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0">Операции по кассе (Внесение/Изъятие)</h5></div>
<div class="card-body">
<form action="{{ url_for('kassa_operation') }}" method="POST">
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label">Касса</label><select name="kassa_id" class="form-select" required><option value="">-- Выберите --</option>{% for k in kassas %}<option value="{{k.id}}">{{k.name}}</option>{% endfor %}</select></div>
<div class="col-md-2"><label class="form-label">Операция</label><select name="op_type" class="form-select" required><option value="deposit">Внесение</option><option value="withdrawal">Изъятие</option></select></div>
<div class="col-md-2"><label class="form-label">Сумма</label><input type="text" name="amount" class="form-control" required inputmode="decimal"></div>
<div class="col-md-3"><label class="form-label">Описание</label><input type="text" name="description" class="form-control"></div>
<div class="col-md-2"><button type="submit" class="btn btn-success w-100">Выполнить</button></div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Учет расходов (операционные)</h5></div>
<div class="card-body d-flex flex-column">
<form action="{{ url_for('manage_expense') }}" method="POST" class="mb-4">
<div class="row g-2 align-items-end">
<div class="col-md-4"><label class="form-label">Сумма</label><input type="text" name="amount" class="form-control" required inputmode="decimal"></div>
<div class="col-md-8"><label class="form-label">Описание</label><input type="text" name="description" class="form-control" required placeholder="Напр: Аренда за май"></div>
<div class="col-12"><button type="submit" class="btn btn-warning w-100 mt-2">Добавить расход</button></div>
</div>
</form>
<h6>Все расходы:</h6>
<div class="table-responsive flex-grow-1" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-striped">
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th><th></th></tr></thead>
<tbody>
{% for e in expenses %}
<tr>
<td><small>{{ e.timestamp[:10] }}</small></td>
<td>{{ e.description }}</td>
<td class="text-end fw-bold">{{ format_currency_py(e.amount) }} ₸</td>
<td class="text-end">
<form action="{{ url_for('delete_expense', expense_id=e.id) }}" method="POST" onsubmit="return confirm('Удалить этот расход?');">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center">Расходов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Учет расходов (личные)</h5></div>
<div class="card-body d-flex flex-column">
<form action="{{ url_for('manage_personal_expense') }}" method="POST" class="mb-4">
<div class="row g-2 align-items-end">
<div class="col-md-4"><label class="form-label">Сумма</label><input type="text" name="amount" class="form-control" required inputmode="decimal"></div>
<div class="col-md-8"><label class="form-label">Описание</label><input type="text" name="description" class="form-control" required placeholder="Напр: Обед"></div>
<div class="col-12"><button type="submit" class="btn btn-info w-100 mt-2">Добавить расход</button></div>
</div>
</form>
<h6>Все расходы:</h6>
<div class="table-responsive flex-grow-1" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-striped">
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th><th></th></tr></thead>
<tbody>
{% for e in personal_expenses %}
<tr>
<td><small>{{ e.timestamp[:10] }}</small></td>
<td>{{ e.description }}</td>
<td class="text-end fw-bold">{{ format_currency_py(e.amount) }} ₸</td>
<td class="text-end">
<form action="{{ url_for('delete_personal_expense', expense_id=e.id) }}" method="POST" onsubmit="return confirm('Удалить этот расход?');">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center">Расходов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h5 class="mb-0">Резервное копирование</h5></div>
<div class="card-body">
<p>Данные периодически сохраняются в облако. Можно сделать это вручную.</p>
<form method="POST" action="{{ url_for('backup_hf') }}" class="d-inline-block me-2">
<button type="submit" class="btn btn-outline-primary"><i class="fas fa-cloud-upload-alt me-2"></i>Сохранить в облако</button>
</form>
<form method="GET" action="{{ url_for('download_hf') }}" class="d-inline-block" onsubmit="return confirm('ВНИМАНИЕ! Это действие заменит все локальные данные данными из облака. Продолжить?');">
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-cloud-download-alt me-2"></i>Загрузить из облака</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Новый кассир</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form action="{{ url_for('manage_user') }}" method="POST">
<input type="hidden" name="action" value="add">
<div class="modal-body">
<div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" class="form-control" required></div>
<div class="mb-3"><label class="form-label">ПИН-код</label><input type="password" name="pin" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Тип оплаты</label><select name="payment_type" class="form-select"><option value="percentage">Процент от продаж</option><option value="salary">Фиксированная зарплата</option></select></div>
<div class="mb-3"><label class="form-label">Значение</label><input type="text" name="payment_value" class="form-control" inputmode="decimal" value="0" required><small class="form-text text-muted">Для процентов - число (напр. 5), для зарплаты - сумма в тенге.</small></div>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
</form>
</div>
</div>
</div>
{% for u in users %}
<div class="modal fade" id="editUserModal-{{ u.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Редактировать кассира</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form action="{{ url_for('manage_user') }}" method="POST">
<input type="hidden" name="action" value="edit"> <input type="hidden" name="id" value="{{ u.id }}">
<div class="modal-body">
<div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" value="{{ u.name }}" class="form-control" required></div>
<div class="mb-3"><label class="form-label">ПИН-код</label><input type="password" name="pin" value="{{ u.pin }}" class="form-control" required></div>
<div class="mb-3"><label class="form-label">Тип оплаты</label><select name="payment_type" class="form-select">
<option value="percentage" {% if u.payment_type == 'percentage' %}selected{% endif %}>Процент от продаж</option>
<option value="salary" {% if u.payment_type == 'salary' %}selected{% endif %}>Фиксированная зарплата</option></select>
</div>
<div class="mb-3"><label class="form-label">Значение</label><input type="text" name="payment_value" class="form-control" value="{{ u.payment_value }}" inputmode="decimal" required></div>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
</form>
</div>
</div>
</div>
{% endfor %}
"""
ADMIN_SCRIPTS = ""
SHIFTS_CONTENT = """
<div class="card">
<div class="card-header"><h5 class="mb-0">История смен</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Кассир</th><th>Касса</th><th>Начало смены</th><th>Конец смены</th>
<th class="text-end">Продажи (наличные)</th><th class="text-end">Продажи (карта)</th><th class="text-end">Итого продаж</th>
</tr>
</thead>
<tbody>
{% for s in shifts %}
<tr>
<td>{{ s.user_name }}</td><td>{{ s.kassa_name }}</td>
<td>{{ s.start_time[:16]|replace('T', ' ') }}</td>
<td>{{ (s.end_time[:16]|replace('T', ' ')) if s.end_time else '<span class="badge bg-success">Активна</span>' }}</td>
<td class="text-end">{{ format_currency_py(s.get('cash_sales', 0)) }} ₸</td>
<td class="text-end">{{ format_currency_py(s.get('card_sales', 0)) }} ₸</td>
<td class="text-end fw-bold">{{ format_currency_py(s.get('total_sales', 0)) }} ₸</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center">Нет данных о сменах.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
ADMIN_LOGIN_CONTENT = """
<div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h4 class="card-title text-center">Вход для администратора</h4>
<form method="POST">
<div class="mb-3"><label for="password" class="form-label">Пароль</label><input type="password" name="password" id="password" class="form-control" required autofocus></div>
<div class="d-grid"><button type="submit" class="btn btn-primary">Войти</button></div>
</form>
</div>
</div>
</div>
</div>
"""
CASHIER_LOGIN_CONTENT = """
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h4 class="card-title text-center">Вход для кассира</h4>
<form method="POST" action="{{ url_for('cashier_login') }}">
<div class="mb-3"><label for="pin" class="form-label">Введите ваш ПИН-код</label><input type="password" name="pin" id="pin" class="form-control form-control-lg text-center" required autofocus></div>
<div class="d-grid"><button type="submit" class="btn btn-primary btn-lg">Войти</button></div>
</form>
</div>
</div>
</div>
</div>
"""
CASHIER_DASHBOARD_CONTENT = """
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Сумма</th><th>Статус</th><th>Действие</th></tr></thead>
<tbody>
{% for t in transactions %}
<tr class="{% if t.type == 'return' %}table-danger{% endif %}">
<td><a href="{{ url_for('view_receipt', transaction_id=t.id) }}" target="_blank"><small class="text-muted">{{ t.id[:8] }}</small></a></td>
<td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
<td><span class="badge bg-{{'primary' if t.type == 'sale' else 'warning'}}">{{'Продажа' if t.type == 'sale' else 'Возврат'}}</span></td>
<td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
<td>
{% if t.status == 'completed' %}<span class="badge bg-success">Завершено</span>
{% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращено</span>
{% elif t.status == 'partially_returned' %}<span class="badge bg-warning text-dark">Частичный возврат</span>
{% else %}<span class="badge bg-secondary">{{t.status}}</span>
{% endif %}
</td>
<td>
{% if t.type == 'sale' and t.status in ['completed', 'partially_returned'] %}
<a href="{{ url_for('return_transaction', transaction_id=t.id, cashier_id=user.id) }}" class="btn btn-sm btn-warning">Возврат</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
RETURN_PAGE_CONTENT = """
<div class="card">
<div class="card-header"><h5 class="mb-0">Оформление возврата по накладной <a href="{{ url_for('view_receipt', transaction_id=transaction.id) }}" target="_blank">{{ transaction.id[:8] }}</a></h5></div>
<div class="card-body">
<form method="POST" action="{{ url_for('return_transaction', transaction_id=transaction.id) }}">
<input type="hidden" name="cashier_id" value="{{ cashier_id }}">
<div class="table-responsive">
<table class="table table-bordered">
<thead><tr><th>Товар</th><th class="text-center">Цена за шт.</th><th class="text-center">Продано</th><th class="text-center">Вернуть (шт.)</th></tr></thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="text-center">{{ format_currency_py(item.price_at_sale) }} ₸</td>
<td class="text-center">{{ item.quantity }}</td>
<td>
<input type="number" name="return_qty_{{ item.variant_id }}" class="form-control" value="0" min="0" max="{{ item.max_returnable }}">
<small class="form-text text-muted">Доступно: {{ item.max_returnable }} шт.</small>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center">Нет товаров, доступных для возврата.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if items %}
<div class="mt-3 d-flex justify-content-end">
<a href="{{ url_for('cashier_dashboard', user_id=cashier_id) }}" class="btn btn-secondary me-2">Отмена</a>
<button type="submit" class="btn btn-warning">Оформить возврат</button>
</div>
{% endif %}
</form>
</div>
</div>
"""
SHOP_CATALOG_CONTENT = """
<div class="mb-4"><input type="text" id="shop-search" class="form-control form-control-lg" placeholder="Поиск по названию товара..."></div>
<div class="row row-cols-2 row-cols-md-3 row-cols-xl-4 g-3 g-md-4" id="shop-product-grid">
{% for p in products %}
<div class="col product-card-wrapper" data-name="{{ p.name|lower }}">
<div class="card product-card h-100">
{% set first_variant_with_images = p.variants | selectattr('image_urls', 'defined') | selectattr('image_urls', 'ne', []) | first %}
{% set images_to_show = first_variant_with_images.image_urls if first_variant_with_images else [url_for('static', filename='placeholder.png')] %}
<div id="carousel-{{ p.id }}" class="carousel slide" data-bs-touch="true" data-bs-interval="false">
<div class="carousel-inner" data-product-id="{{ p.id }}">
{% for img_url in images_to_show %}<div class="carousel-item {% if loop.first %}active{% endif %}"><img src="{{ img_url }}" class="d-block w-100" alt="{{ p.name }}"></div>{% endfor %}
</div>
</div>
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ p.name }}</h5>
<p class="price-range mt-auto">
{% if p.variants|length > 1 %}{% set prices = p.variants|map(attribute='price')|map('float')|list %}{{ format_currency_py(prices|min) }} ₸ - {{ format_currency_py(prices|max) }} ₸
{% elif p.variants|length == 1 %}{{ format_currency_py(p.variants[0].price) }} ₸
{% endif %}
</p>
<p class="card-text text-muted small mb-1">В наличии: {{ p.total_stock }} шт.</p>
<div class="d-grid mt-2"><button class="btn btn-primary btn-sm add-to-cart-trigger" data-product-id="{{ p.id }}"><i class="fas fa-shopping-cart me-1"></i> Выбрать</button></div>
</div>
</div>
</div>
{% else %}<div class="col-12"><p class="text-center text-muted">Нет товаров в наличии.</p></div>
{% endfor %}
</div>
"""
SHOP_CATALOG_SCRIPTS = """
<script>
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('shop-search');
const productGrid = document.getElementById('shop-product-grid');
const productCards = Array.from(productGrid.getElementsByClassName('product-card-wrapper'));
const modalElement = document.getElementById('productDetailModal');
const productDetailModal = new bootstrap.Modal(modalElement);
let cart = JSON.parse(localStorage.getItem('shopCart')) || {};
const saveCart = () => localStorage.setItem('shopCart', JSON.stringify(cart));
const formatCurrencyJS = (v) => (parseFloat(String(v).replace(',', '.')) || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₸';
const updateCartUI = () => {
const cartContent = document.getElementById('cart-content');
const cartItemCount = document.getElementById('cart-item-count');
const cartSummary = document.getElementById('cart-summary');
let totalQty = 0; let totalAmount = 0; let contentHtml = '';
if (Object.keys(cart).length === 0) {
cartContent.innerHTML = '<p class="text-muted text-center pt-3">Корзина пуста.</p>';
cartSummary.innerHTML = '';
} else {
contentHtml = '<div class="list-group list-group-flush">';
for (const itemKey in cart) {
const item = cart[itemKey];
const itemTotal = item.quantity * (parseFloat(String(item.price).replace(',', '.')) || 0);
totalQty += item.quantity;
totalAmount += itemTotal;
contentHtml += `
<div class="list-group-item px-0 py-3">
<div class="d-flex w-100 justify-content-between">
<div>
<h6 class="mb-1 small">${item.productName}</h6>
<span class="badge bg-secondary">${item.color}</span>
</div>
<button class="btn-close btn-sm remove-item-from-cart" data-item-key="${itemKey}"></button>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-outline-secondary cart-qty-btn-shop" data-item-key="${itemKey}" data-op="-1">-</button>
<input type="number" class="form-control form-control-sm text-center mx-1 cart-qty-input-shop" data-item-key="${itemKey}" value="${item.quantity}" style="width: 70px;" min="1">
<button class="btn btn-sm btn-outline-secondary cart-qty-btn-shop" data-item-key="${itemKey}" data-op="1">+</button>
</div>
<strong class="text-end">${formatCurrencyJS(itemTotal)}</strong>
</div>
</div>`;
}
contentHtml += '</div>';
cartContent.innerHTML = contentHtml;
cartSummary.innerHTML = `<div class="d-flex justify-content-between mb-2"><span>Всего товаров:</span><strong>${totalQty} шт.</strong></div><div class="d-flex justify-content-between fs-5"><span>Итого:</span><strong>${formatCurrencyJS(totalAmount)}</strong></div>`;
}
cartItemCount.textContent = totalQty;
document.getElementById('create-order-btn').disabled = totalQty === 0;
};
const cartContentEl = document.getElementById('cart-content');
cartContentEl.addEventListener('click', e => {
const removeBtn = e.target.closest('.remove-item-from-cart');
const qtyBtn = e.target.closest('.cart-qty-btn-shop');
if (removeBtn) {
delete cart[removeBtn.dataset.itemKey];
saveCart(); updateCartUI();
}
if (qtyBtn) {
const itemKey = qtyBtn.dataset.itemKey;
if (cart[itemKey]) {
const newQty = cart[itemKey].quantity + parseInt(qtyBtn.dataset.op);
if (newQty > 0) cart[itemKey].quantity = newQty;
else delete cart[itemKey];
saveCart(); updateCartUI();
}
}
});
cartContentEl.addEventListener('change', e => {
const qtyInput = e.target.closest('.cart-qty-input-shop');
if (qtyInput) {
const itemKey = qtyInput.dataset.itemKey;
if (cart[itemKey]) {
const newQty = parseInt(qtyInput.value, 10);
if (!isNaN(newQty) && newQty > 0) {
cart[itemKey].quantity = newQty;
} else {
delete cart[itemKey];
}
saveCart();
updateCartUI();
}
}
});
searchInput.addEventListener('input', e => {
const searchTerm = e.target.value.toLowerCase();
productCards.forEach(c => c.style.display = c.dataset.name.includes(searchTerm) ? '' : 'none');
});
const renderModalContent = (product) => {
const placeholderImg = "{{ url_for('static', filename='placeholder.png') }}";
const image = (product.variants?.[0]?.image_urls?.[0]) ? product.variants[0].image_urls[0] : placeholderImg;
const price = (product.variants?.[0]) ? product.variants[0].price : '0';
const colors = product.variants?.[0]?.colors || [];
let colorsHtml = '<p class="text-muted">Нет доступных цветов для этого варианта.</p>';
if (colors.length > 0) {
colorsHtml = colors.map(color => {
const itemKey = `${product.id}_${color}`;
const currentQuantity = cart[itemKey] ? cart[itemKey].quantity : 0;
return `
<li class="list-group-item d-flex justify-content-between align-items-center" data-color="${color}">
<span>${color}</span>
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-outline-secondary modal-qty-btn" data-op="-1">-</button>
<input type="number" class="form-control form-control-sm text-center mx-1 modal-qty-input" value="${currentQuantity}" style="width: 60px;" min="0">
<button class="btn btn-sm btn-outline-secondary modal-qty-btn" data-op="1">+</button>
</div>
</li>
`;
}).join('');
colorsHtml = `<ul class="list-group list-group-flush" id="modal-color-list" data-product-id="${product.id}" data-product-name="${product.name}" data-price="${price}">${colorsHtml}</ul>`;
}
const modalBody = document.getElementById('productDetailModalBody');
modalBody.innerHTML = `
<div class="modal-header"><h5 class="modal-title">${product.name}</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="row">
<div class="col-md-5">
<img src="${image}" class="img-fluid rounded mb-3" alt="${product.name}">
<p class="fs-5 fw-bold text-primary text-center">${formatCurrencyJS(price)}</p>
</div>
<div class="col-md-7">
<h6>Выберите количество для каждого цвета:</h6>
${colorsHtml}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>`;
};
modalElement.addEventListener('click', e => {
const qtyBtn = e.target.closest('.modal-qty-btn');
if(qtyBtn) {
const input = qtyBtn.parentElement.querySelector('.modal-qty-input');
let currentVal = parseInt(input.value, 10);
if(isNaN(currentVal)) currentVal = 0;
const op = parseInt(qtyBtn.dataset.op, 10);
const newVal = Math.max(0, currentVal + op);
input.value = newVal;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
modalElement.addEventListener('change', e => {
const qtyInput = e.target.closest('.modal-qty-input');
if(qtyInput) {
const list = qtyInput.closest('#modal-color-list');
const listItem = qtyInput.closest('.list-group-item');
if(!list || !listItem) return;
const productId = list.dataset.productId;
const productName = list.dataset.productName;
const price = list.dataset.price;
const color = listItem.dataset.color;
const quantity = parseInt(qtyInput.value, 10);
const itemKey = `${productId}_${color}`;
if (!isNaN(quantity) && quantity > 0) {
cart[itemKey] = { productId, productName, price, color, quantity };
} else {
delete cart[itemKey];
}
saveCart();
updateCartUI();
}
});
const openProductModal = (productId) => {
fetch(`/api/product_detail/${productId}`).then(r => r.json()).then(d => {
if (d.success) { renderModalContent(d.product); productDetailModal.show(); }
else alert(d.message);
}).catch(e => alert('Не удалось загрузить информацию о товаре.'));
};
productGrid.addEventListener('click', e => {
const trigger = e.target.closest('.add-to-cart-trigger, .carousel-inner');
if (trigger) { const pId = trigger.dataset.productId; if (pId) openProductModal(pId); }
});
document.getElementById('create-order-btn').addEventListener('click', e => {
e.target.disabled = true;
e.target.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Создание...';
fetch('/api/create_order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cart) })
.then(res => res.json()).then(data => {
if (data.success) { cart = {}; saveCart(); updateCartUI(); window.open(data.orderUrl, '_blank'); }
else { alert('Ошибка: ' + data.message); }
}).catch(err => { alert('Сетевая ошибка.'); })
.finally(() => { e.target.disabled = false; e.target.textContent = 'Оформить заказ'; });
});
updateCartUI();
});
</script>
"""
DEBTS_CONTENT = """
<div class="card">
<div class="card-header"><h5 class="mb-0">Долги</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr><th>Дата</th><th>Клиент</th><th>Срок</th><th class="text-end">Сумма</th><th>Статус</th><th></th></tr>
</thead>
<tbody>
{% for debt in debts %}
<tr class="{% if debt.status == 'unpaid' %}table-warning{% endif %}">
<td>{{ debt.timestamp[:16]|replace('T', ' ') }}</td>
<td>{{ debt.name }}</td>
<td>{{ debt.dueDate }}</td>
<td class="text-end fw-bold">{{ format_currency_py(debt.amount) }} ₸</td>
<td>
{% if debt.status == 'paid' %}<span class="badge bg-success">Погашен</span>
{% else %}<span class="badge bg-danger">Не погашен</span>
{% endif %}
</td>
<td class="text-end">
{% if debt.status == 'unpaid' %}
<form action="{{ url_for('pay_debt', debt_id=debt.id) }}" method="POST" onsubmit="return confirm('Отметить долг как погашенный?');">
<button type="submit" class="btn btn-sm btn-success">Погасить</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center">Нет записей о долгах.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
"""
ONLINE_ORDERS_CONTENT = """
<div class="accordion" id="ordersAccordion">
{% for order in orders %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-order-{{ order.id }}">
<span class="fw-bold me-2">Заказ №{{ order.id[:8] }}</span>
<span class="text-muted me-3">от {{ order.timestamp[:16]|replace('T', ' ') }}</span>
<span class="badge ms-auto
{% if order.status == 'Новый заказ' %}bg-primary
{% elif order.status == 'Ожидает оплаты' %}bg-warning text-dark
{% elif order.status == 'Отправлен' %}bg-success
{% elif order.status == 'Подтвержден' %}bg-info
{% else %}bg-secondary{% endif %}">{{ order.status }}</span>
</button>
</h2>
<div id="collapse-order-{{ order.id}}" class="accordion-collapse collapse" data-bs-parent="#ordersAccordion">
<div class="accordion-body">
<div class="row">
<div class="col-md-8">
<div class="order-details-container mb-3" id="order-details-{{ order.id }}"></div>
</div>
<div class="col-md-4">
<h6>Управление заказом</h6>
<div class="mb-3">
<label class="form-label">Сменить статус:</label>
<select class="form-select form-select-sm status-select" data-order-id="{{ order.id }}">
<option value="Новый заказ" {% if order.status == 'Новый заказ' %}selected{% endif %}>Новый заказ</option>
<option value="Ожидает оплаты" {% if order.status == 'Ожидает оплаты' %}selected{% endif %}>Ожидает оплаты</option>
<option value="Отправлен" {% if order.status == 'Отправлен' %}selected{% endif %}>Отправлен</option>
<option value="Подтвержден" {% if order.status == 'Подтвержден' %}selected{% endif %}>Подтвержден (списан)</option>
</select>
</div>
{% if session.admin_logged_in and order.status != 'Подтвержден' %}
<div class="d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-primary w-100" onclick="toggleEditMode('{{ order.id }}')">Редактировать состав</button>
<form action="{{ url_for('confirm_order', order_id=order.id) }}" method="POST" onsubmit="return confirm('Подтвердить заказ? Остатки на складе будут уменьшены.');">
<button type="submit" class="btn btn-sm btn-success w-100">Подтвердить и списать</button>
</form>
<form action="{{ url_for('delete_order', order_id=order.id) }}" method="POST" onsubmit="return confirm('Удалить заказ безвозвратно?');">
<button type="submit" class="btn btn-sm btn-danger w-100">Удалить заказ</button>
</form>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% else %}
<p class="text-muted">Заказов нет.</p>
{% endfor %}
</div>
"""
ONLINE_ORDERS_SCRIPTS = """
<script>
const allOrdersData = JSON.parse('{{ orders|tojson|safe }}');
const formatCurrency = (value) => (parseFloat(String(value).replace(',', '.')) || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₸';
function generateOrderHtml(orderData) {
let itemsHtml = ""; let totalQuantity = 0; let totalAmount = 0;
for (const [productId, productData] of Object.entries(orderData.items)) {
const price = parseFloat(String(productData.price).replace(',', '.')) || 0;
let itemsByColorHtml = ""; let productTotalQty = 0;
for (const item of productData.items) {
const itemQty = parseInt(item.quantity, 10) || 0;
productTotalQty += itemQty;
const itemSubtotal = price * itemQty;
itemsByColorHtml += `
<div class="d-flex justify-content-between align-items-center border-bottom py-1" data-color="${item.color}">
<span>Цвет: <strong>${item.color}</strong></span>
<div class="editable-qty" style="display:none;">
<input type="number" class="form-control form-control-sm" value="${itemQty}" min="0" style="width: 70px;">
</div>
<span class="static-qty">Кол-во: <strong>${itemQty} шт.</strong></span>
</div>`;
}
const productTotalAmount = price * productTotalQty;
totalQuantity += productTotalQty;
totalAmount += productTotalAmount;
itemsHtml += `
<div class="d-flex gap-3 border rounded p-2 mb-2" data-product-id="${productId}">
<img src="${productData.image}" alt="${productData.name}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 4px;">
<div class="flex-grow-1">
<p class="fw-bold mb-1">${productData.name}</p>
<p class="small text-muted mb-1">Цена: ${formatCurrency(price)}</p>
${itemsByColorHtml}
</div>
</div>`;
}
return `
<div class="border-bottom pb-2 mb-2 d-flex justify-content-between">
<span>Итого позиций: <strong>${totalQuantity} шт.</strong></span>
<span>Общая сумма: <strong class="fs-5">${formatCurrency(totalAmount)}</strong></span>
</div>
${itemsHtml}
<div class="edit-controls mt-3" style="display:none;">
<button class="btn btn-primary btn-sm" onclick="saveOrderChanges('${orderData.id}')">Сохранить</button>
<button class="btn btn-secondary btn-sm" onclick="toggleEditMode('${orderData.id}')">Отмена</button>
</div>
`;
}
function toggleEditMode(orderId) {
const container = document.getElementById(`order-details-${orderId}`);
container.querySelectorAll('.editable-qty, .static-qty, .edit-controls').forEach(el => el.style.display = el.style.display === 'none' ? '' : 'none');
}
function saveOrderChanges(orderId) {
const container = document.getElementById(`order-details-${orderId}`);
const updatedItems = {};
container.querySelectorAll('[data-product-id]').forEach(prodEl => {
const productId = prodEl.dataset.productId;
updatedItems[productId] = [];
prodEl.querySelectorAll('[data-color]').forEach(colorEl => {
updatedItems[productId].push({
color: colorEl.dataset.color,
quantity: colorEl.querySelector('input').value
});
});
});
fetch(`/api/order/edit_items/${orderId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: updatedItems})
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('Заказ обновлен.');
window.location.reload();
} else {
alert('Ошибка: ' + data.message);
}
});
}
document.addEventListener('DOMContentLoaded', () => {
allOrdersData.forEach(order => {
const container = document.getElementById(`order-details-${order.id}`);
if(container) container.innerHTML = generateOrderHtml(order);
});
document.querySelectorAll('.status-select').forEach(select => {
select.addEventListener('change', e => {
const orderId = e.target.dataset.orderId;
const newStatus = e.target.value;
fetch(`/api/order/update_status/${orderId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: newStatus})
}).then(res => res.json()).then(data => {
if(!data.success) alert('Ошибка: ' + data.message);
else window.location.reload();
});
});
});
});
</script>
"""
if __name__ == '__main__':
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
for key in DATA_FILES.keys():
if key == 'settings':
load_json_data(key, default_value={})
else:
load_json_data(key)
sync_images_from_hf()
app.run(debug=False, host='0.0.0.0', port=7860, use_reloader=False)