Spaces:
Sleeping
Sleeping
| 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 | |
| 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", "jigitadmin") | |
| 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') | |
| CLIENTS_FILE = os.path.join(DATA_DIR, 'clients.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()), | |
| 'clients': (CLIENTS_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/muzhpos" | |
| BISHKEK_TZ = pytz.timezone('Asia/Bishkek') | |
| SIZES = ["S", "M", "L", "XL", "2XL", "3XL", "4XL", "5XL", "6XL", "7XL", "8XL"] | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| def get_current_time(): | |
| return datetime.now(BISHKEK_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 load_json_data(file_key): | |
| 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: | |
| return json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| return [] if file_key != 'clients' 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) | |
| 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): | |
| items_html = "" | |
| for item in transaction['items']: | |
| item_name_with_size = item['name'] | |
| if item.get('size'): | |
| item_name_with_size += f" ({item['size']})" | |
| items_html += f""" | |
| <tr> | |
| <td>{item_name_with_size}</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.get('discount_per_item', '0'))}</td> | |
| <td style="text-align: right;">{format_currency_py(item['total'])}</td> | |
| </tr> | |
| """ | |
| payment_method_display = "Наличные" | |
| if transaction['payment_method'] == 'card': | |
| payment_method_display = "Карта" | |
| elif transaction['payment_method'] == 'debt': | |
| payment_method_display = f"В долг ({transaction.get('client_name', '')})" | |
| 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: 'Courier New', monospace; margin: 0; padding: 20px; background-color: #f4f4f4; }} | |
| .receipt {{ max-width: 350px; margin: auto; background: white; padding: 20px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }} | |
| h2, p {{ text-align: center; margin: 5px 0; }} | |
| table {{ width: 100%; border-collapse: collapse; margin: 15px 0; }} | |
| th, td {{ padding: 5px; border-bottom: 1px dashed #ccc; font-size: 0.9em;}} | |
| .total {{ font-weight: bold; font-size: 1.2em; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="receipt"> | |
| <h2>Jigit_Shopping</h2> | |
| <p>--------------------------------</p> | |
| <p>Чек №: {transaction['id'][:8]}</p> | |
| <p>Дата: {datetime.fromisoformat(transaction['timestamp']).strftime('%d.%m.%Y %H:%M')}</p> | |
| <p>Кассир: {transaction['user_name']}</p> | |
| <p>Клиент: {transaction.get('client_name', 'Розничный покупатель')}</p> | |
| <p>--------------------------------</p> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style="text-align: left;">Товар</th> | |
| <th style="text-align: right;">Кол.</th> | |
| <th style="text-align: right;">Цена</th> | |
| <th style="text-align: right;">Скидка</th> | |
| <th style="text-align: right;">Сумма</th> | |
| </tr> | |
| </thead> | |
| <tbody>{items_html}</tbody> | |
| </table> | |
| <p>--------------------------------</p> | |
| <p class="total">Итого: {format_currency_py(transaction['total_amount'])} с</p> | |
| <p>Способ оплаты: {payment_method_display}</p> | |
| <p>--------------------------------</p> | |
| <p>Спасибо за покупку!</p> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| def admin_required(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 | |
| def inject_utils(): | |
| return {'format_currency_py': format_currency_py, 'get_current_time': get_current_time, 'quote': quote, 'SIZES': SIZES} | |
| def sales_screen(): | |
| inventory = load_json_data('inventory') | |
| kassas = load_json_data('kassas') | |
| clients = list(load_json_data('clients').values()) | |
| active_inventory = [] | |
| for p in inventory: | |
| if isinstance(p, dict) and any(sum(v.get('sizes', {}).values()) > 0 for v in p.get('variants', [])): | |
| active_inventory.append(p) | |
| active_inventory.sort(key=lambda x: x.get('name', '').lower()) | |
| clients.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, clients=clients, grouped_inventory=sorted_grouped_inventory) | |
| 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_image_urls = request.form.getlist('variant_image_url[]') | |
| size_stocks = {size: request.form.getlist(f'variant_stock_{size}[]') for size in SIZES} | |
| for i in range(len(variant_names)): | |
| v_name = variant_names[i].strip() | |
| if not v_name: continue | |
| sizes_data = {size: int(to_decimal(size_stocks[size][i], '0')) for size in SIZES} | |
| total_stock = sum(sizes_data.values()) | |
| 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': total_stock, | |
| 'sizes': sizes_data, | |
| 'image_url': variant_image_urls[i] if i < len(variant_image_urls) else '' | |
| }) | |
| 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) | |
| 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_image_urls = request.form.getlist('variant_image_url[]') | |
| size_stocks = {size: request.form.getlist(f'variant_stock_{size}[]') for size in SIZES} | |
| for j in range(len(variant_ids)): | |
| v_name = variant_names[j].strip() | |
| if not v_name: continue | |
| sizes_data = {size: int(to_decimal(size_stocks[size][j], '0')) for size in SIZES} | |
| total_stock = sum(sizes_data.values()) | |
| 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': total_stock, | |
| 'sizes': sizes_data, | |
| 'image_url': variant_image_urls[j] if j < len(variant_image_urls) else '' | |
| }) | |
| 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')) | |
| 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')) | |
| def stock_in(): | |
| try: | |
| product_id = request.form.get('product_id') | |
| variant_id = request.form.get('variant_id') | |
| size = request.form.get('size') | |
| 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 not size 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', '') | |
| if 'sizes' not in variant: variant['sizes'] = {s: 0 for s in SIZES} | |
| old_stock_size = variant['sizes'].get(size, 0) | |
| variant['sizes'][size] = old_stock_size + quantity | |
| old_total_stock = variant.get('stock', 0) | |
| new_total_stock = old_total_stock + quantity | |
| variant['stock'] = new_total_stock | |
| old_cost = to_decimal(variant.get('cost_price', '0')) | |
| new_cost = to_decimal(cost_price_str) if cost_price_str else old_cost | |
| if new_total_stock > 0: | |
| avg_cost = ((old_cost * old_total_stock) + (new_cost * quantity) + delivery_cost) / new_total_stock | |
| 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} / {size})" | |
| } | |
| 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} / {size})' увеличен на {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')) | |
| def upload_image(): | |
| if 'image' not in request.files: | |
| return jsonify({'success': False, 'message': 'No file part'}), 400 | |
| file = request.files['image'] | |
| if file.filename == '': | |
| return jsonify({'success': False, 'message': 'No selected file'}), 400 | |
| if file: | |
| try: | |
| 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) | |
| url = url_for('static', filename=f'product_images/{filename}') | |
| return jsonify({'success': True, 'url': url}) | |
| except Exception as e: | |
| logging.error(f"Image upload failed: {e}", exc_info=True) | |
| return jsonify({'success': False, 'message': f'Server error: {e}'}), 500 | |
| return jsonify({'success': False, 'message': 'Unknown error'}), 500 | |
| 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 | |
| def get_clients(): | |
| clients = load_json_data('clients') | |
| return jsonify(list(clients.values())) | |
| def get_client_transactions(client_id): | |
| transactions = load_json_data('transactions') | |
| client_transactions = [t for t in transactions if t.get('client_id') == client_id] | |
| client_transactions.sort(key=lambda x: x['timestamp'], reverse=True) | |
| for t in client_transactions: | |
| t['timestamp_formatted'] = datetime.fromisoformat(t['timestamp']).strftime('%d.%m.%Y %H:%M') | |
| t['total_amount_formatted'] = format_currency_py(t['total_amount']) | |
| return jsonify(client_transactions) | |
| 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') | |
| client_id = data.get('clientId') | |
| payment_method = data.get('paymentMethod', 'cash') | |
| 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') | |
| clients = load_json_data('clients') | |
| user = find_item_by_field(users, 'id', user_id) | |
| kassa = find_item_by_field(kassas, 'id', kassa_id) | |
| client = clients.get(client_id) if client_id else None | |
| if not user or not kassa: | |
| return jsonify({'success': False, 'message': 'Кассир или касса не найдены.'}), 404 | |
| if payment_method == 'debt' and not client: | |
| return jsonify({'success': False, 'message': 'Клиент не выбран для продажи в долг.'}), 400 | |
| sale_items = [] | |
| total_amount = Decimal('0.00') | |
| inventory_updates = {} | |
| for item_key, 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) | |
| total_amount += item_total | |
| sale_items.append({ | |
| 'product_id': None, | |
| 'variant_id': item_key, | |
| 'size': None, | |
| 'name': cart_item.get('productName', 'Товар без штрихкода'), | |
| 'barcode': 'CUSTOM', | |
| 'quantity': quantity_sold, | |
| 'price_at_sale': str(price_at_sale), | |
| 'cost_price_at_sale': '0.00', | |
| 'discount_per_item': '0.00', | |
| 'total': str(item_total), | |
| 'is_custom': True | |
| }) | |
| continue | |
| variant_id, size = item_key.split('_') | |
| 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('sizes', {}).get(size, 0) | |
| if quantity_sold > current_stock: | |
| return jsonify({'success': False, 'message': f"Недостаточно товара '{product['name']} ({variant['option_value']} / {size})'. В наличии: {current_stock}"}), 400 | |
| price_at_sale = to_decimal(variant.get('price', '0')) | |
| cost_price_at_sale = to_decimal(variant.get('cost_price', '0')) | |
| discount_per_item = to_decimal(cart_item.get('discount', '0')) | |
| if discount_per_item > price_at_sale: | |
| return jsonify({'success': False, 'message': f"Скидка на '{product['name']}' не может быть больше цены."}), 400 | |
| final_price = price_at_sale - discount_per_item | |
| item_total = final_price * Decimal(quantity_sold) | |
| total_amount += item_total | |
| sale_items.append({ | |
| 'product_id': product['id'], | |
| 'variant_id': variant_id, | |
| 'size': size, | |
| '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), | |
| 'discount_per_item': str(discount_per_item), | |
| 'total': str(item_total) | |
| }) | |
| update_key = f"{variant_id}_{size}" | |
| inventory_updates[update_key] = { | |
| 'product_id': product['id'], | |
| 'variant_id': variant_id, | |
| 'size': size, | |
| 'change': -quantity_sold | |
| } | |
| 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, | |
| 'client_id': client_id, | |
| 'client_name': client['name'] if client else 'Розничный покупатель', | |
| 'items': sale_items, | |
| 'total_amount': str(total_amount), | |
| 'payment_method': payment_method | |
| } | |
| new_transaction['receipt_html'] = generate_receipt_html(new_transaction) | |
| transactions = load_json_data('transactions') | |
| transactions.append(new_transaction) | |
| for _, update in inventory_updates.items(): | |
| for p in inventory: | |
| if p.get('id') == update['product_id']: | |
| for v in p.get('variants', []): | |
| if v.get('id') == update['variant_id']: | |
| v['sizes'][update['size']] += update['change'] | |
| v['stock'] += update['change'] | |
| 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 | |
| elif payment_method == 'debt': | |
| client['debt'] = str(to_decimal(client.get('debt', '0')) + total_amount) | |
| client.setdefault('history', []).append({ | |
| 'type': 'purchase_debt', 'amount': str(total_amount), 'timestamp': now_iso, | |
| 'transaction_id': new_transaction['id'] | |
| }) | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| 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 | |
| def view_receipt(transaction_id): | |
| transactions = load_json_data('transactions') | |
| transaction = find_item_by_field(transactions, 'id', transaction_id) | |
| if transaction and 'receipt_html' in transaction: | |
| return Response(transaction['receipt_html'], mimetype='text/html') | |
| abort(404, description="Чек не найден") | |
| 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') | |
| 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, selected_date=selected_date_str, kassas=kassas, selected_kassa_id=selected_kassa_id) | |
| 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') | |
| clients = load_json_data('clients') | |
| 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_total_amount = Decimal('0.00') | |
| for item in original_transaction['items']: | |
| item_key = f"{item.get('variant_id')}_{item.get('size')}" if item.get('size') else item.get('variant_id') | |
| update_data = find_item_by_field(items_update, 'id', item_key) | |
| if update_data: | |
| new_price = to_decimal(update_data.get('price', item['price_at_sale'])) | |
| new_discount = to_decimal(update_data.get('discount', item.get('discount_per_item', '0'))) | |
| item['price_at_sale'] = str(new_price) | |
| item['discount_per_item'] = str(new_discount) | |
| item_total = (new_price - new_discount) * Decimal(item['quantity']) | |
| item['total'] = str(item_total) | |
| new_total_amount += to_decimal(item['total']) | |
| transactions[transaction_index]['total_amount'] = str(new_total_amount) | |
| transactions[transaction_index].setdefault('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]['receipt_html'] = generate_receipt_html(transactions[transaction_index]) | |
| amount_diff = new_total_amount - old_total_amount | |
| if amount_diff != Decimal(0): | |
| if 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') | |
| elif original_transaction['payment_method'] == 'debt': | |
| client_id = original_transaction.get('client_id') | |
| if client_id and client_id in clients: | |
| clients[client_id]['debt'] = str(to_decimal(clients[client_id].get('debt', '0')) + amount_diff) | |
| clients[client_id].setdefault('history', []).append({ | |
| 'type': 'correction', 'amount': str(amount_diff), 'timestamp': get_current_time().isoformat(), | |
| 'description': f"Корректировка транзакции {transaction_id[:8]}" | |
| }) | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| 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 | |
| def delete_transaction(transaction_id): | |
| transactions = load_json_data('transactions') | |
| inventory = load_json_data('inventory') | |
| kassas = load_json_data('kassas') | |
| clients = load_json_data('clients') | |
| transaction_to_delete = find_item_by_field(transactions, 'id', transaction_id) | |
| if not transaction_to_delete: | |
| flash("Транзакция не найдена.", "danger") | |
| return redirect(url_for('transaction_history')) | |
| change_multiplier = 1 if transaction_to_delete.get('type') == 'sale' else -1 | |
| 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) * change_multiplier | |
| size = item.get('size') | |
| if size: | |
| variant['sizes'][size] = variant.get('sizes', {}).get(size, 0) + quantity_change | |
| variant['stock'] = variant.get('stock', 0) + quantity_change | |
| amount_change = to_decimal(transaction_to_delete.get('total_amount')) | |
| payment_method = transaction_to_delete.get('payment_method') | |
| if payment_method == 'cash': | |
| kassa = find_item_by_field(kassas, 'id', transaction_to_delete.get('kassa_id')) | |
| if kassa: | |
| kassa['balance'] = str(to_decimal(kassa.get('balance', '0')) - amount_change) | |
| kassa.setdefault('history', []).append({ | |
| 'type': 'deletion', 'amount': str(-amount_change), 'timestamp': get_current_time().isoformat(), | |
| 'description': f"Удаление транзакции {transaction_id[:8]}" | |
| }) | |
| elif payment_method == 'debt': | |
| client_id = transaction_to_delete.get('client_id') | |
| if client_id and client_id in clients: | |
| clients[client_id]['debt'] = str(to_decimal(clients[client_id].get('debt', '0')) - amount_change) | |
| clients[client_id].setdefault('history', []).append({ | |
| 'type': 'deletion', 'amount': str(-amount_change), 'timestamp': get_current_time().isoformat(), | |
| 'description': f"Удаление транзакции {transaction_id[:8]}" | |
| }) | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| 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')) | |
| 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=BISHKEK_TZ) | |
| end_date = (datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)).replace(tzinfo=BISHKEK_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 and t.get('type') == 'sale' | |
| ] | |
| 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 | |
| ] | |
| 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 | |
| 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: | |
| 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: | |
| 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) if num_days > 0 else daily_salary | |
| 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, | |
| '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) | |
| 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'] == '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'])) | |
| total_qty_sold += item['quantity'] | |
| elif t['type'] == 'return': | |
| 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'])) | |
| 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) | |
| 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') | |
| 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) | |
| 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) | |
| 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')) | |
| 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')) | |
| 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')) | |
| 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')) | |
| 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')) | |
| 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')) | |
| 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')) | |
| 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) | |
| 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 | |
| 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}) | |
| 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': 'Смена успешно закрыта'}) | |
| 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) | |
| def return_transaction(transaction_id): | |
| cashier_id = request.form.get('cashier_id') | |
| if not cashier_id: | |
| flash("Не удалось определить кассира для оформления возврата.", "danger") | |
| return redirect(url_for('cashier_login')) | |
| transactions = load_json_data('transactions') | |
| inventory = load_json_data('inventory') | |
| kassas = load_json_data('kassas') | |
| clients = load_json_data('clients') | |
| original_transaction = find_item_by_field(transactions, 'id', transaction_id) | |
| if not original_transaction: | |
| flash("Оригинальная транзакция не найдена.", "danger") | |
| return redirect(url_for('cashier_dashboard', user_id=cashier_id)) | |
| if original_transaction.get('status') == 'returned': | |
| flash("Эта продажа уже была возвращена.", "warning") | |
| return redirect(url_for('cashier_dashboard', user_id=cashier_id)) | |
| total_amount = to_decimal(original_transaction['total_amount']) | |
| return_items = [] | |
| inventory_updates = {} | |
| for item in original_transaction['items']: | |
| return_items.append({**item, 'quantity': -item['quantity'], 'total': str(-to_decimal(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', item['variant_id']) | |
| if variant: | |
| update_key = f"{item['variant_id']}_{item['size']}" | |
| inventory_updates[update_key] = { | |
| 'product_id': item['product_id'], | |
| 'variant_id': item['variant_id'], | |
| 'size': item['size'], | |
| 'change': item['quantity'] | |
| } | |
| now_iso = get_current_time().isoformat() | |
| return_transaction = { | |
| 'id': uuid.uuid4().hex, | |
| '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'), | |
| 'client_id': original_transaction.get('client_id'), | |
| 'client_name': original_transaction.get('client_name'), | |
| 'items': return_items, | |
| 'total_amount': str(-total_amount), | |
| 'payment_method': original_transaction['payment_method'] | |
| } | |
| transactions.append(return_transaction) | |
| for i, t in enumerate(transactions): | |
| if t['id'] == transaction_id: | |
| transactions[i]['status'] = 'returned' | |
| break | |
| for _, update in inventory_updates.items(): | |
| for p in inventory: | |
| if p.get('id') == update['product_id']: | |
| for v in p.get('variants', []): | |
| if v.get('id') == update['variant_id']: | |
| v['sizes'][update['size']] += update['change'] | |
| v['stock'] += update['change'] | |
| p['timestamp_updated'] = now_iso | |
| break | |
| break | |
| payment_method = original_transaction['payment_method'] | |
| if payment_method == 'cash': | |
| for i, k in enumerate(kassas): | |
| if k['id'] == original_transaction['kassa_id']: | |
| current_balance = to_decimal(k.get('balance', '0')) | |
| kassas[i]['balance'] = str(current_balance - total_amount) | |
| kassas[i].setdefault('history', []).append({ | |
| 'type': 'return', 'amount': str(-total_amount), 'timestamp': now_iso, | |
| 'transaction_id': return_transaction['id'] | |
| }) | |
| break | |
| elif payment_method == 'debt': | |
| client_id = original_transaction.get('client_id') | |
| if client_id and client_id in clients: | |
| clients[client_id]['debt'] = str(to_decimal(clients[client_id].get('debt', '0')) - total_amount) | |
| clients[client_id].setdefault('history', []).append({ | |
| 'type': 'return_debt', 'amount': str(-total_amount), 'timestamp': now_iso, | |
| 'transaction_id': return_transaction['id'] | |
| }) | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| 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)) | |
| def clients_page(): | |
| clients_dict = load_json_data('clients') | |
| clients_list = sorted(list(clients_dict.values()), key=lambda x: x['name']) | |
| html = BASE_TEMPLATE.replace('__TITLE__', "Клиенты").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS) | |
| return render_template_string(html, clients=clients_list) | |
| def manage_client(): | |
| action = request.form.get('action') | |
| clients = load_json_data('clients') | |
| if action == 'add' or action == 'edit': | |
| name = request.form.get('name', '').strip() | |
| phone = request.form.get('phone', '').strip() | |
| if not name: | |
| flash("Имя клиента - обязательное поле.", "danger") | |
| return redirect(url_for('clients_page')) | |
| if action == 'add': | |
| client_id = uuid.uuid4().hex | |
| clients[client_id] = { | |
| 'id': client_id, | |
| 'name': name, | |
| 'phone': phone, | |
| 'debt': '0.00', | |
| 'history': [] | |
| } | |
| flash(f"Клиент '{name}' добавлен.", "success") | |
| else: # edit | |
| client_id = request.form.get('id') | |
| if client_id in clients: | |
| clients[client_id]['name'] = name | |
| clients[client_id]['phone'] = phone | |
| flash(f"Данные клиента '{name}' обновлены.", "success") | |
| else: | |
| flash("Клиент не найден.", "danger") | |
| elif action == 'delete': | |
| client_id = request.form.get('id') | |
| if client_id in clients: | |
| del clients[client_id] | |
| flash("Клиент удален.", "success") | |
| else: | |
| flash("Клиент не найден.", "warning") | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| return redirect(url_for('clients_page')) | |
| def debts_page(): | |
| clients_dict = load_json_data('clients') | |
| all_clients = sorted(list(clients_dict.values()), key=lambda x: x['name']) | |
| debtors = [c for c in clients_dict.values() if to_decimal(c.get('debt', '0')) != 0] | |
| debtors.sort(key=lambda x: to_decimal(x.get('debt', '0')), reverse=True) | |
| kassas = load_json_data('kassas') | |
| html = BASE_TEMPLATE.replace('__TITLE__', "Долги").replace('__CONTENT__', DEBTS_CONTENT).replace('__SCRIPTS__', DEBTS_SCRIPTS) | |
| return render_template_string(html, debtors=debtors, kassas=kassas, all_clients=all_clients) | |
| def debt_operation(): | |
| client_id = request.form.get('client_id') | |
| op_type = request.form.get('op_type') | |
| amount = to_decimal(request.form.get('amount', '0')) | |
| kassa_id = request.form.get('kassa_id') | |
| description = request.form.get('description', '').strip() | |
| if not client_id or not op_type or amount <= 0: | |
| flash("Неверные данные для операции с долгом.", "danger") | |
| return redirect(url_for('debts_page')) | |
| if op_type == 'payment' and not kassa_id: | |
| flash("Выберите кассу для зачисления платежа.", "danger") | |
| return redirect(url_for('debts_page')) | |
| clients = load_json_data('clients') | |
| kassas = load_json_data('kassas') | |
| if client_id not in clients: | |
| flash("Клиент не найден.", "danger") | |
| return redirect(url_for('debts_page')) | |
| client = clients[client_id] | |
| current_debt = to_decimal(client.get('debt', '0')) | |
| now_iso = get_current_time().isoformat() | |
| if op_type == 'payment': | |
| client['debt'] = str(current_debt - amount) | |
| client.setdefault('history', []).append({ | |
| 'type': 'payment', 'amount': str(-amount), 'timestamp': now_iso, | |
| 'description': description or "Погашение долга" | |
| }) | |
| kassa = find_item_by_field(kassas, 'id', kassa_id) | |
| if kassa: | |
| kassa['balance'] = str(to_decimal(kassa.get('balance', '0')) + amount) | |
| kassa.setdefault('history', []).append({ | |
| 'type': 'debt_payment', 'amount': str(amount), 'timestamp': now_iso, | |
| 'description': f"Погашение долга: {client['name']}" | |
| }) | |
| save_json_data('kassas', kassas) | |
| upload_db_to_hf('kassas') | |
| flash(f"Платеж от '{client['name']}' на сумму {format_currency_py(amount)} списан.", "success") | |
| elif op_type == 'add_debt': | |
| client['debt'] = str(current_debt + amount) | |
| client.setdefault('history', []).append({ | |
| 'type': 'manual_debt', 'amount': str(amount), 'timestamp': now_iso, | |
| 'description': description or "Ручное добавление долга" | |
| }) | |
| flash(f"Долг для '{client['name']}' увеличен на {format_currency_py(amount)}.", "success") | |
| save_json_data('clients', clients) | |
| upload_db_to_hf('clients') | |
| return redirect(url_for('debts_page')) | |
| 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')) | |
| 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}") | |
| if success_count > 0: | |
| flash(f"Успешно загружено {success_count} файлов. Данные перезаписаны.", "success") | |
| if errors: | |
| flash("Произошли ошибки: " + "; ".join(errors), "danger") | |
| return redirect(url_for('admin_panel')) | |
| 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) | |
| 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"> | |
| <link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet"> | |
| <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; overflow-y: auto; } | |
| .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"] .ts-control, [data-bs-theme="dark"] .ts-dropdown { background: #343a40 !important; color: #dee2e6 !important; border-color: #495057 !important; } | |
| [data-bs-theme="dark"] .ts-dropdown .option { color: #dee2e6 !important; } | |
| [data-bs-theme="dark"] .ts-dropdown .active { background-color: #495057 !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); } | |
| .variant-sizes-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; } | |
| </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 {% 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 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> | |
| {% if session.admin_logged_in %} | |
| <li class="nav-item"><a class="nav-link {% if request.endpoint == 'clients_page' %}active{% endif %}" href="{{ url_for('clients_page') }}"><i class="fas fa-fw fa-users me-2"></i>Клиенты</a></li> | |
| <li class="nav-item"><a class="nav-link {% if request.endpoint == 'debts_page' %}active{% endif %}" href="{{ url_for('debts_page') }}"><i class="fas fa-fw fa-book me-2"></i>Долги</a></li> | |
| {% endif %} | |
| <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" id="sidebar-toggle"><i class="fas fa-bars"></i></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">+996</span> | |
| <input type="tel" class="form-control" id="whatsapp-phone" placeholder="555123456"> | |
| </div> | |
| <div class="form-text">Введите номер без +996.</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> | |
| <div class="modal fade" id="clientPurchaseHistoryModal" tabindex="-1"> | |
| <div class="modal-dialog modal-xl"> | |
| <div class="modal-content"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title" id="clientPurchaseHistoryModalTitle">История покупок</h5> | |
| <button type="button" class="btn-close" data-bs-dismiss="modal"></button> | |
| </div> | |
| <div class="modal-body" id="clientPurchaseHistoryModalBody" style="max-height: 70vh; overflow-y: auto;"> | |
| <p class="text-center">Загрузка...</p> | |
| </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 src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script> | |
| <script> | |
| function formatCurrencyJS(num) { | |
| return (num || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}); | |
| } | |
| function showClientPurchaseHistory(clientId, clientName) { | |
| const modal = new bootstrap.Modal(document.getElementById('clientPurchaseHistoryModal')); | |
| const titleEl = document.getElementById('clientPurchaseHistoryModalTitle'); | |
| const bodyEl = document.getElementById('clientPurchaseHistoryModalBody'); | |
| titleEl.textContent = `История покупок: ${clientName}`; | |
| bodyEl.innerHTML = '<p class="text-center">Загрузка...</p>'; | |
| modal.show(); | |
| fetch(`/api/client/${clientId}/transactions`) | |
| .then(res => res.json()) | |
| .then(transactions => { | |
| if (transactions.length === 0) { | |
| bodyEl.innerHTML = '<p class="text-center text-muted">У клиента еще нет покупок.</p>'; | |
| return; | |
| } | |
| let tableHtml = `<table class="table table-sm table-hover"><thead><tr><th>Дата</th><th>ID</th><th>Тип</th><th>Сумма</th><th>Позиции</th></tr></thead><tbody>`; | |
| transactions.forEach(t => { | |
| let itemsHtml = '<ul class="list-unstyled mb-0 small">'; | |
| t.items.forEach(item => { | |
| itemsHtml += `<li>${item.name} (${item.quantity} шт.)</li>`; | |
| }); | |
| itemsHtml += '</ul>'; | |
| const typeBadge = t.type === 'sale' ? '<span class="badge bg-primary">Продажа</span>' : '<span class="badge bg-danger">Возврат</span>'; | |
| tableHtml += ` | |
| <tr> | |
| <td>${t.timestamp_formatted}</td> | |
| <td><a href="/receipt/${t.id}" target="_blank">${t.id.substring(0,8)}</a></td> | |
| <td>${typeBadge}</td> | |
| <td class="text-end fw-bold">${t.total_amount_formatted} с</td> | |
| <td>${itemsHtml}</td> | |
| </tr> | |
| `; | |
| }); | |
| tableHtml += '</tbody></table>'; | |
| bodyEl.innerHTML = tableHtml; | |
| }) | |
| .catch(err => { | |
| bodyEl.innerHTML = '<p class="text-center text-danger">Ошибка загрузки истории.</p>'; | |
| console.error(err); | |
| }); | |
| } | |
| 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); | |
| }); | |
| document.body.addEventListener('click', e => { | |
| const historyBtn = e.target.closest('.client-purchase-history-btn'); | |
| if (historyBtn) { | |
| e.preventDefault(); | |
| const clientId = historyBtn.dataset.clientId; | |
| const clientName = historyBtn.dataset.clientName; | |
| if (clientId && clientName) { | |
| showClientPurchaseHistory(clientId, clientName); | |
| } | |
| } | |
| }); | |
| }); | |
| </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 class="mb-3"> | |
| <label for="client-select" class="form-label">Клиент</label> | |
| <div class="input-group"> | |
| <select id="client-select"> | |
| <option value="">Розничный покупатель</option> | |
| {% for c in clients %} | |
| <option value="{{ c.id }}" data-name="{{c.name}}">{{ c.name }}</option> | |
| {% endfor %} | |
| </select> | |
| <button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#addClientModal"><i class="fas fa-plus"></i></button> | |
| </div> | |
| <div id="client-debt-info" class="form-text text-danger" style="display: none;"></div> | |
| <div id="client-actions-container" class="mt-2"></div> | |
| </div> | |
| <div id="cart-items" class="list-group mb-3" style="max-height: 400px; overflow-y: auto;"></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> | |
| </div> | |
| <button class="btn btn-warning" id="pay-debt-btn" disabled><i class="fas fa-book me-2"></i>В долг</button> | |
| <button class="btn btn-danger" 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="addClientModal" 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 id="add-client-form"> | |
| <div class="modal-body"> | |
| <div class="mb-3"><label class="form-label">Имя</label><input type="text" id="new-client-name" class="form-control" required></div> | |
| <div class="mb-3"><label class="form-label">Телефон</label><input type="tel" id="new-client-phone" class="form-control"></div> | |
| </div> | |
| <div class="modal-footer"><button type="submit" class="btn btn-primary">Добавить</button></div> | |
| </form> | |
| </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 cartTotalEl = document.getElementById('cart-total'); | |
| let audioCtx; | |
| let isScannerPaused = false; | |
| let clientsData = {{ clients|tojson }}; | |
| 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 addClientModal = new bootstrap.Modal(document.getElementById('addClientModal')); | |
| let clientSelect = new TomSelect('#client-select', { | |
| create: false, | |
| sortField: { field: 'text', direction: 'asc' } | |
| }); | |
| 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 formatCurrency = (num) => (num || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}); | |
| const updateCartView = () => { | |
| cartItemsEl.innerHTML = ''; | |
| let total = 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]; | |
| total += (parseLocaleNumber(item.price) - parseLocaleNumber(item.discount)) * item.quantity; | |
| let displayName = `${item.productName} ${item.variantName ? '('+item.variantName+')' : ''}`; | |
| if (item.size) displayName += ` / ${item.size}`; | |
| cartItemsEl.innerHTML += ` | |
| <div class="list-group-item"> | |
| <div class="d-flex justify-content-between align-items-start"> | |
| <div> | |
| <h6 class="mb-0 small">${displayName}</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 class="input-group input-group-sm mt-2"> | |
| <span class="input-group-text">Скидка</span> | |
| <input type="text" class="form-control cart-discount-input" data-id="${id}" value="${item.discount}" inputmode="decimal"> | |
| </div> | |
| </div>`; | |
| } | |
| cartTotalEl.textContent = formatCurrency(total) + ' с'; | |
| }; | |
| const addToCart = (product, variant, size) => { | |
| const cartKey = `${variant.id}_${size}`; | |
| if (cart[cartKey]) { | |
| cart[cartKey].quantity++; | |
| } else { | |
| cart[cartKey] = { | |
| productId: product.id, | |
| productName: product.name, | |
| variantName: variant.option_value, | |
| size: size, | |
| price: String(variant.price).replace('.',','), | |
| quantity: 1, | |
| discount: '0' | |
| }; | |
| } | |
| playBeep(); | |
| updateCartView(); | |
| }; | |
| const handleProductSelection = (product) => { | |
| if (!product.variants || product.variants.length === 0) { | |
| alert("У этого товара нет доступных вариантов."); return; | |
| } | |
| if (product.variants.length === 1 && Object.keys(product.variants[0].sizes).length === 1) { | |
| const variant = product.variants[0]; | |
| const size = Object.keys(variant.sizes)[0]; | |
| addToCart(product, variant, size); | |
| } else { | |
| document.getElementById('variantSelectModalTitle').textContent = `Выберите вариант для "${product.name}"`; | |
| const variantList = document.getElementById('variant-list'); | |
| variantList.innerHTML = ''; | |
| product.variants.forEach(variant => { | |
| if (variant.stock <= 0) return; | |
| const item = document.createElement('div'); | |
| item.className = 'list-group-item'; | |
| const availableSizes = Object.entries(variant.sizes).filter(([size, stock]) => stock > 0); | |
| if (availableSizes.length === 0) return; | |
| let sizeButtons = ''; | |
| availableSizes.forEach(([size, stock]) => { | |
| sizeButtons += `<button type="button" class="btn btn-outline-primary btn-sm size-select-btn" data-product='${JSON.stringify(product)}' data-variant='${JSON.stringify(variant)}' data-size="${size}">${size} (${stock})</button>`; | |
| }); | |
| item.innerHTML = ` | |
| <div class="d-flex justify-content-between align-items-center"> | |
| <div class="d-flex align-items-center"> | |
| ${variant.image_url ? `<img src="${variant.image_url}" class="img-thumbnail me-3" style="width: 50px; height: 50px; object-fit: cover;">` : ''} | |
| <div>${variant.option_value} - <strong>${formatCurrency(parseFloat(variant.price))} с</strong></div> | |
| </div> | |
| </div> | |
| <div class="mt-2 btn-group" role="group">${sizeButtons}</div>`; | |
| variantList.appendChild(item); | |
| }); | |
| variantList.querySelectorAll('.size-select-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const p = JSON.parse(e.currentTarget.dataset.product); | |
| const v = JSON.parse(e.currentTarget.dataset.variant); | |
| const s = e.currentTarget.dataset.size; | |
| addToCart(p, v, s); | |
| variantSelectModal.hide(); | |
| }); | |
| }); | |
| 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]) updateCartItemQuantity(id, cart[id].quantity + op); | |
| } | |
| }); | |
| cartItemsEl.addEventListener('change', e => { | |
| if (e.target.classList.contains('cart-qty-input')) updateCartItemQuantity(e.target.dataset.id, e.target.value); | |
| if (e.target.classList.contains('cart-discount-input')) { | |
| const id = e.target.dataset.id; | |
| if (cart[id]) { | |
| cart[id].discount = e.target.value; | |
| updateCartView(); | |
| } | |
| } | |
| }); | |
| document.getElementById('clear-cart-btn').addEventListener('click', () => { | |
| for(const id in cart) delete cart[id]; | |
| updateCartView(); | |
| }); | |
| document.getElementById('product-search').addEventListener('input', e => { | |
| const term = e.target.value.toLowerCase(); | |
| const productCards = document.querySelectorAll('#product-accordion .product-card'); | |
| productCards.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 matchingCardsInGroup = accordionItem.querySelectorAll('.product-card:not([style*="display: none"])'); | |
| const bsCollapse = bootstrap.Collapse.getOrCreateInstance(collapseElement, { toggle: false }); | |
| if (term === '') bsCollapse.hide(); | |
| else if (matchingCardsInGroup.length > 0) bsCollapse.show(); | |
| else bsCollapse.hide(); | |
| }); | |
| }); | |
| const completeSale = (paymentMethod) => { | |
| if (!session.shift || !session.cashier || !session.kassa) { | |
| alert('Смена не активна. Начните смену, чтобы проводить продажи.'); return; | |
| } | |
| if (Object.keys(cart).length === 0) { | |
| alert('Корзина пуста!'); return; | |
| } | |
| const clientId = clientSelect.getValue(); | |
| if(paymentMethod === 'debt' && !clientId) { | |
| alert('Выберите клиента для продажи в долг.'); return; | |
| } | |
| fetch('/api/complete_sale', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| cart, | |
| userId: session.cashier.id, | |
| kassaId: session.kassa.id, | |
| shiftId: session.shift.id, | |
| clientId: clientId, | |
| paymentMethod: paymentMethod | |
| }) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| if (data.success) { | |
| for(const id in cart) delete cart[id]; | |
| updateCartView(); | |
| document.getElementById('receipt-url').value = data.receiptUrl; | |
| document.getElementById('view-receipt-btn').href = data.receiptUrl; | |
| receiptModal.show(); | |
| if (paymentMethod === 'debt') updateClientData(); | |
| } 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', () => completeSale('debt')); | |
| 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) { | |
| window.open(`https://wa.me/996${phone}?text=${encodeURIComponent(`Ваш чек: ${receiptUrl}`)}`, '_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(); | |
| }, 1500); | |
| }; | |
| const startScanner = () => { | |
| document.getElementById('scanner-container').style.display = 'block'; | |
| const config = { fps: 25, qrbox: { width: 300, height: 150 } }; | |
| html5QrCode.start({ facingMode: "environment" }, config, 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 (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 (!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, discount: '0', isCustom: true | |
| }; | |
| updateCartView(); | |
| customItemModal.hide(); | |
| e.target.reset(); | |
| } else { | |
| alert('Введите корректную цену и количество.'); | |
| } | |
| }); | |
| const updateClientData = () => { | |
| fetch('/api/clients').then(res => res.json()).then(data => { | |
| clientsData = data; | |
| const currentVal = clientSelect.getValue(); | |
| clientSelect.clear(); | |
| clientSelect.clearOptions(); | |
| clientSelect.addOption({value: '', text: 'Розничный покупатель'}); | |
| data.sort((a,b) => a.name.localeCompare(b.name)).forEach(c => { | |
| clientSelect.addOption({value: c.id, text: c.name, 'data-name': c.name}); | |
| }); | |
| clientSelect.setValue(currentVal); | |
| handleClientSelect(); | |
| }); | |
| }; | |
| const handleClientSelect = () => { | |
| const clientId = clientSelect.getValue(); | |
| const debtBtn = document.getElementById('pay-debt-btn'); | |
| const debtInfo = document.getElementById('client-debt-info'); | |
| const actionsContainer = document.getElementById('client-actions-container'); | |
| actionsContainer.innerHTML = ''; | |
| if (clientId) { | |
| debtBtn.disabled = false; | |
| const client = clientsData.find(c => c.id === clientId); | |
| const clientName = clientSelect.options[clientId].text; | |
| if(client) { | |
| if (parseLocaleNumber(client.debt) > 0) { | |
| debtInfo.textContent = `Долг: ${formatCurrency(parseLocaleNumber(client.debt))} с`; | |
| debtInfo.style.display = 'block'; | |
| } else { | |
| debtInfo.style.display = 'none'; | |
| } | |
| const historyBtn = document.createElement('button'); | |
| historyBtn.className = 'btn btn-outline-info btn-sm client-purchase-history-btn'; | |
| historyBtn.innerHTML = '<i class="fas fa-history"></i> История покупок'; | |
| historyBtn.dataset.clientId = clientId; | |
| historyBtn.dataset.clientName = clientName; | |
| actionsContainer.appendChild(historyBtn); | |
| } | |
| } else { | |
| debtBtn.disabled = true; | |
| debtInfo.style.display = 'none'; | |
| } | |
| }; | |
| clientSelect.on('change', handleClientSelect); | |
| document.getElementById('add-client-form').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const name = document.getElementById('new-client-name').value; | |
| const phone = document.getElementById('new-client-phone').value; | |
| fetch('/admin/client', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
| body: new URLSearchParams({action: 'add', name, phone}) | |
| }).then(res => { | |
| if(res.ok) { | |
| addClientModal.hide(); | |
| e.target.reset(); | |
| updateClientData(); | |
| } else { alert('Ошибка добавления клиента'); } | |
| }); | |
| }); | |
| 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> | |
| {% for v in p.variants %} | |
| <div class="card mb-2"> | |
| <div class="card-header"> | |
| <strong>{{ v.option_value }}</strong> | Цена: {{ format_currency_py(v.price) }} с | Себест: {{ format_currency_py(v.cost_price) }} с | Всего: {{v.stock}} шт. | |
| </div> | |
| <div class="card-body p-2"> | |
| <table class="table table-sm table-bordered mb-0"> | |
| <thead> | |
| <tr>{% for size in SIZES[:4] %}<th>{{size}}</th>{% endfor %}</tr> | |
| </thead> | |
| <tbody> | |
| <tr>{% for size in SIZES[:4] %}<td>{{ v.sizes.get(size, 0) }}</td>{% endfor %}</tr> | |
| </tbody> | |
| <thead> | |
| <tr>{% for size in SIZES[4:8] %}<th>{{size}}</th>{% endfor %}</tr> | |
| </thead> | |
| <tbody> | |
| <tr>{% for size in SIZES[4:8] %}<td>{{ v.sizes.get(size, 0) }}</td>{% endfor %}</tr> | |
| </tbody> | |
| <thead> | |
| <tr>{% for size in SIZES[8:] %}<th>{{size}}</th>{% endfor %}{% for i in range(4 - SIZES[8:]|length) %}<th></th>{% endfor %}</tr> | |
| </thead> | |
| <tbody> | |
| <tr>{% for size in SIZES[8:] %}<td>{{ v.sizes.get(size, 0) }}</td>{% endfor %}{% for i in range(4 - SIZES[8:]|length) %}<td></td>{% endfor %}</tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| {% else %} | |
| <p class="text-center text-muted">Нет вариантов</p> | |
| {% endfor %} | |
| </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="card mb-3 variant-row"> | |
| <div class="card-body"> | |
| <input type="hidden" name="variant_id[]" value="{{ v.id }}"> | |
| <div class="row g-2 mb-2 align-items-center"> | |
| <div class="col-md-2"> | |
| <img src="{{ v.image_url if v.image_url else url_for('static', filename='placeholder.png') }}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;"> | |
| <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*"> | |
| <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value="{{ v.image_url }}"> | |
| </div> | |
| <div class="col-md-3"><label>Вариант</label><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" value="{{ v.option_value }}" required></div> | |
| <div class="col-md-2"><label>Цена</label><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" value="{{ v.price|string|replace('.', ',') }}" inputmode="decimal"></div> | |
| <div class="col-md-2"><label>Себестоимость</label><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" value="{{ v.cost_price|string|replace('.', ',') }}" inputmode="decimal"></div> | |
| <div class="col-md-1"><button type="button" class="btn btn-sm btn-danger remove-variant-btn mt-4"><i class="fas fa-times"></i></button></div> | |
| </div> | |
| <h6>Остатки по размерам</h6> | |
| <div class="variant-sizes-grid"> | |
| {% for size in SIZES %} | |
| <div class="input-group input-group-sm"> | |
| <span class="input-group-text">{{size}}</span> | |
| <input type="number" name="variant_stock_{{size}}[]" class="form-control" value="{{ v.sizes.get(size, 0) }}"> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </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="mb-3"> | |
| <label for="stockin-size" class="form-label">Размер</label> | |
| <select id="stockin-size" name="size" class="form-select" required> | |
| {% for size in SIZES %}<option value="{{size}}">{{size}}</option>{% endfor %} | |
| </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; | |
| const placeholderImg = "{{ url_for('static', filename='placeholder.png') }}"; | |
| 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; | |
| html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 } }, (decodedText) => { | |
| barcodeInput.value = decodedText; | |
| try { html5QrCode.stop(); } catch(e) {} | |
| currentScanner = null; | |
| scannerContainer.style.display = 'none'; | |
| }); | |
| }); | |
| }); | |
| 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 handleImageUpload = (fileInput) => { | |
| const file = fileInput.files[0]; | |
| if (!file) return; | |
| const row = fileInput.closest('.variant-row'); | |
| const preview = row.querySelector('.variant-preview'); | |
| const urlInput = row.querySelector('.variant-image-url-input'); | |
| const formData = new FormData(); | |
| formData.append('image', file); | |
| preview.src = "data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="; | |
| fetch("{{ url_for('upload_image') }}", { method: 'POST', body: formData }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.success) { | |
| preview.src = data.url; | |
| urlInput.value = data.url; | |
| } else { | |
| alert('Ошибка загрузки: ' + data.message); | |
| preview.src = placeholderImg; | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Upload error:', error); | |
| alert('Сетевая ошибка при загрузке изображения.'); | |
| preview.src = placeholderImg; | |
| }); | |
| }; | |
| const createVariantRow = () => { | |
| const sizes = {{ SIZES|tojson }}; | |
| let sizeInputs = ''; | |
| sizes.forEach(size => { | |
| sizeInputs += ` | |
| <div class="input-group input-group-sm"> | |
| <span class="input-group-text">${size}</span> | |
| <input type="number" name="variant_stock_${size}[]" class="form-control" value="0"> | |
| </div>`; | |
| }); | |
| const div = document.createElement('div'); | |
| div.className = 'card mb-3 variant-row'; | |
| div.innerHTML = ` | |
| <div class="card-body"> | |
| <input type="hidden" name="variant_id[]" value=""> | |
| <div class="row g-2 mb-2 align-items-center"> | |
| <div class="col-md-2"> | |
| <img src="${placeholderImg}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;"> | |
| <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*"> | |
| <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value=""> | |
| </div> | |
| <div class="col-md-3"><label>Вариант</label><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" required></div> | |
| <div class="col-md-2"><label>Цена</label><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" inputmode="decimal"></div> | |
| <div class="col-md-2"><label>Себестоимость</label><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" inputmode="decimal"></div> | |
| <div class="col-md-1"><button type="button" class="btn btn-sm btn-danger remove-variant-btn mt-4"><i class="fas fa-times"></i></button></div> | |
| </div> | |
| <h6>Остатки по размерам</h6> | |
| <div class="variant-sizes-grid">${sizeInputs}</div> | |
| </div>`; | |
| div.querySelector('.remove-variant-btn').addEventListener('click', () => div.remove()); | |
| div.querySelector('.variant-image-upload').addEventListener('change', (e) => handleImageUpload(e.target)); | |
| return div; | |
| }; | |
| document.body.addEventListener('change', e => { | |
| if (e.target.classList.contains('variant-image-upload')) { | |
| handleImageUpload(e.target); | |
| } | |
| }); | |
| document.getElementById('add-variant-btn-add').addEventListener('click', () => { | |
| document.getElementById('variants-container-add').appendChild(createVariantRow()); | |
| }); | |
| document.querySelectorAll('.add-variant-btn-edit').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| document.getElementById(e.target.dataset.targetContainer).appendChild(createVariantRow()); | |
| }); | |
| }); | |
| document.body.addEventListener('click', e => { | |
| const removeBtn = e.target.closest('.remove-variant-btn'); | |
| if (removeBtn) { | |
| removeBtn.closest('.variant-row').remove(); | |
| } | |
| }); | |
| document.getElementById('addProductModal').addEventListener('shown.bs.modal', () => { | |
| const container = document.getElementById('variants-container-add'); | |
| if (container.children.length === 0) container.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 productId = productSelect.value; | |
| variantSelect.innerHTML = '<option value="">-- Выберите вариант --</option>'; | |
| variantSelect.disabled = true; | |
| if (productId) { | |
| const product = inventoryData.find(p => p.id === productId); | |
| if (product && product.variants) { | |
| product.variants.forEach(v => { | |
| variantSelect.innerHTML += `<option value="${v.id}">${v.option_value}</option>`; | |
| }); | |
| variantSelect.disabled = false; | |
| } | |
| } | |
| }); | |
| document.getElementById('inventory-search').addEventListener('input', e => { | |
| const term = e.target.value.toLowerCase(); | |
| document.querySelectorAll('#inventoryAccordion .accordion-item').forEach(item => { | |
| const productName = item.querySelector('.accordion-button strong').textContent.toLowerCase(); | |
| const barcode = item.querySelector('.accordion-button small').textContent.toLowerCase(); | |
| const variantNames = Array.from(item.querySelectorAll('.accordion-body .card-header strong')).some(el => el.textContent.toLowerCase().includes(term)); | |
| item.style.display = (productName.includes(term) || barcode.includes(term) || variantNames) ? '' : '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 %} | |
| <h4 class="mb-0">Общая выручка за {{ selected_date }}{% if selected_kassa %} (Касса: {{ selected_kassa.name }}){% endif %}: <span class="text-success">{{ format_currency_py(total_sales) }} с</span></h4> | |
| </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) if t.receipt_html else '#' }}" 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">В долг</span> | |
| {% endif %} | |
| {% elif t.type == 'return' %}<span class="badge bg-danger">Возврат</span> | |
| {% endif %} | |
| </td> | |
| <td>{{ t.user_name }}<br><small class="text-muted">{{ t.get('client_name') }}</small></td><td>{{ t.kassa_name }}</td> | |
| <td class="fw-bold">{{ format_currency_py(t.total_amount) }} с</td> | |
| <td><span class="badge bg-{{'success' if t.status == 'completed' else 'secondary'}}">{{t.status}}</span></td> | |
| <td> | |
| <ul class="list-unstyled mb-0 small"> | |
| {% for item in t['items'] %}<li>{{ item.name }}{% if item.size %} ({{item.size}}){% endif %} ({{ item.quantity }}x{{ format_currency_py(item.price_at_sale) }}{% if item.get('discount_per_item', '0')|float > 0 %} -{{ format_currency_py(item.discount_per_item) }}{% endif %})</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><th>Скидка</th></tr></thead><tbody>`; | |
| items.forEach(item => { | |
| const itemId = item.size ? `${item.variant_id}_${item.size}` : item.variant_id; | |
| const itemName = item.size ? `${item.name} (${item.size})` : item.name; | |
| tableHtml += ` | |
| <tr data-item-id="${itemId}"> | |
| <td>${itemName} (${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> | |
| <td><input type="text" class="form-control form-control-sm" name="discount" value="${String(item.discount_per_item || '0').replace('.',',')}" inputmode="decimal"></td> | |
| </tr>`; | |
| }); | |
| tableHtml += `</tbody></table>`; | |
| container.innerHTML = tableHtml; | |
| }); | |
| document.getElementById('save-trans-btn').addEventListener('click', () => { | |
| const items_update = Array.from(document.querySelectorAll('#edit-trans-form tbody tr')).map(row => ({ | |
| id: row.dataset.itemId, | |
| price: row.querySelector('input[name="price"]').value, | |
| discount: row.querySelector('input[name="discount"]').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-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"> | |
| <a href="{{ url_for('admin_shifts') }}" class="btn btn-info"><i class="fas fa-history me-2"></i>История смен кассиров</a> | |
| </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><small class="text-muted">{{ t.id[:8] }}</small></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><span class="badge bg-{{'success' if t.status == 'completed' else 'secondary'}}">{{t.status}}</span></td> | |
| <td> | |
| {% if t.type == 'sale' and t.status == 'completed' %} | |
| <form action="{{ url_for('return_transaction', transaction_id=t.id) }}" method="POST" onsubmit="return confirm('Оформить возврат по этому чеку?');"> | |
| <input type="hidden" name="cashier_id" value="{{ user.id }}"> | |
| <button type="submit" class="btn btn-sm btn-warning">Возврат</button> | |
| </form> | |
| {% endif %} | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| CLIENTS_CONTENT = """ | |
| <div class="card"> | |
| <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="#addClientModal"><i class="fas fa-plus me-2"></i>Добавить клиента</button> | |
| </div> | |
| <div class="card-body"> | |
| <input type="text" id="client-search" class="form-control mb-3" placeholder="Поиск по имени или телефону..."> | |
| <div class="table-responsive"> | |
| <table class="table table-hover mb-0" id="clients-table"> | |
| <thead><tr><th>Имя</th><th>Телефон</th><th class="text-end">Долг</th><th></th></tr></thead> | |
| <tbody> | |
| {% for client in clients %} | |
| <tr> | |
| <td>{{ client.name }}</td> | |
| <td>{{ client.phone }}</td> | |
| <td class="text-end {% if client.debt|float > 0 %}text-danger fw-bold{% endif %}">{{ format_currency_py(client.debt) }} с</td> | |
| <td class="text-end"> | |
| <button class="btn btn-sm btn-outline-info client-purchase-history-btn" data-client-id="{{ client.id }}" data-client-name="{{ client.name }}"><i class="fas fa-shopping-cart"></i> Покупки</button> | |
| <button class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#debtHistoryModal-{{ client.id }}"><i class="fas fa-book"></i> Долги</button> | |
| <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editClientModal-{{ client.id }}"><i class="fas fa-edit"></i></button> | |
| <form action="{{ url_for('manage_client') }}" method="POST" class="d-inline" onsubmit="return confirm('Удалить клиента?');"> | |
| <input type="hidden" name="action" value="delete"> | |
| <input type="hidden" name="id" value="{{ client.id }}"> | |
| <button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button> | |
| </form> | |
| </td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="4" class="text-center text-muted">Клиенты не найдены.</td></tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal fade" id="addClientModal" 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_client') }}" 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="tel" name="phone" class="form-control"></div> | |
| </div> | |
| <div class="modal-footer"><button type="submit" class="btn btn-primary">Добавить</button></div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| {% for client in clients %} | |
| <div class="modal fade" id="editClientModal-{{ client.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_client') }}" method="POST"> | |
| <input type="hidden" name="action" value="edit"> | |
| <input type="hidden" name="id" value="{{ client.id }}"> | |
| <div class="modal-body"> | |
| <div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" value="{{ client.name }}" class="form-control" required></div> | |
| <div class="mb-3"><label class="form-label">Телефон</label><input type="tel" name="phone" value="{{ client.phone }}" class="form-control"></div> | |
| </div> | |
| <div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal fade" id="debtHistoryModal-{{ client.id }}" tabindex="-1"> | |
| <div class="modal-dialog modal-lg"> | |
| <div class="modal-content"> | |
| <div class="modal-header"><h5 class="modal-title">История долга: {{ client.name }}</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> | |
| <div class="modal-body"> | |
| <ul class="list-group"> | |
| {% for item in client.history|sort(attribute='timestamp', reverse=true) %} | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <div> | |
| {{ item.timestamp[:16]|replace('T', ' ') }}<br> | |
| <small class="text-muted">{{ item.description or 'Продажа' }}</small> | |
| </div> | |
| <span class="badge bg-{% if item.amount|float > 0 %}danger{% else %}success{% endif %} rounded-pill">{{ format_currency_py(item.amount) }} с</span> | |
| </li> | |
| {% else %} | |
| <li class="list-group-item">История пуста.</li> | |
| {% endfor %} | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| """ | |
| CLIENTS_SCRIPTS = """ | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const searchInput = document.getElementById('client-search'); | |
| const tableBody = document.querySelector('#clients-table tbody'); | |
| const rows = tableBody.getElementsByTagName('tr'); | |
| searchInput.addEventListener('keyup', () => { | |
| const filter = searchInput.value.toLowerCase(); | |
| for (let i = 0; i < rows.length; i++) { | |
| let nameCol = rows[i].getElementsByTagName('td')[0]; | |
| let phoneCol = rows[i].getElementsByTagName('td')[1]; | |
| if (nameCol || phoneCol) { | |
| let nameTxt = nameCol.textContent || nameCol.innerText; | |
| let phoneTxt = phoneCol.textContent || phoneCol.innerText; | |
| if (nameTxt.toLowerCase().indexOf(filter) > -1 || phoneTxt.toLowerCase().indexOf(filter) > -1) { | |
| rows[i].style.display = ""; | |
| } else { | |
| rows[i].style.display = "none"; | |
| } | |
| } | |
| } | |
| }); | |
| }); | |
| </script> | |
| """ | |
| DEBTS_CONTENT = """ | |
| <div class="card mb-4"> | |
| <div class="card-header"><h5 class="mb-0">Операции с долгами</h5></div> | |
| <div class="card-body"> | |
| <form action="{{ url_for('debt_operation') }}" method="POST"> | |
| <div class="row g-2 align-items-end"> | |
| <div class="col-md-3"><label class="form-label">Клиент</label> | |
| <select name="client_id" id="debt-client-select" required> | |
| <option value="">-- Выберите клиента --</option> | |
| {% for c in all_clients %}<option value="{{c.id}}">{{c.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="payment">Погашение долга</option> | |
| <option value="add_debt">Добавить долг</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> | |
| <select name="kassa_id" class="form-select"> | |
| <option value="">-- Для погашения --</option> | |
| {% for k in kassas %}<option value="{{k.id}}">{{k.name}}</option>{% endfor %} | |
| </select> | |
| </div> | |
| <div class="col-md-2"><button type="submit" class="btn btn-success w-100">Выполнить</button></div> | |
| <div class="col-12 mt-2"><label class="form-label">Описание</label><input type="text" name="description" class="form-control" placeholder="Необязательно"></div> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <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 mb-0"> | |
| <thead><tr><th>Клиент</th><th class="text-end">Сумма долга</th></tr></thead> | |
| <tbody> | |
| {% for debtor in debtors %} | |
| <tr> | |
| <td>{{ debtor.name }}</td> | |
| <td class="text-end fw-bold text-danger">{{ format_currency_py(debtor.debt) }} с</td> | |
| </tr> | |
| {% else %} | |
| <tr><td colspan="2" class="text-center text-muted">Нет должников.</td></tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| DEBTS_SCRIPTS = """ | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new TomSelect('#debt-client-select', { | |
| create: false, | |
| sortField: { field: 'text', direction: 'asc' } | |
| }); | |
| }); | |
| </script> | |
| """ | |
| if __name__ == '__main__': | |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) | |
| backup_thread.start() | |
| for key in DATA_FILES.keys(): | |
| load_json_data(key) | |
| app.run(debug=False, host='0.0.0.0', port=7860, use_reloader=False) | |