|
|
| 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"> |
| © {{ 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> <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) |