diff --git "a/app.py" "b/app.py"
new file mode 100644--- /dev/null
+++ "b/app.py"
@@ -0,0 +1,3791 @@
+
+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)
+
+@functools.lru_cache(maxsize=1)
+def get_hf_api():
+ if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE":
+ return None
+ try:
+ return HfApi()
+ except Exception as e:
+ logging.error(f"Error initializing HfApi: {e}")
+ return None
+
+def upload_db_to_hf(file_key):
+ api = get_hf_api()
+ if not api:
+ return
+ filepath, _ = DATA_FILES[file_key]
+ if not os.path.exists(filepath):
+ return
+ try:
+ filename = os.path.basename(filepath)
+ commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z')
+ api.upload_file(
+ path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset",
+ token=HF_TOKEN_WRITE, commit_message=f"Automated backup {filename} {commit_time}",
+ run_as_future=True
+ )
+ except Exception as e:
+ logging.error(f"Error initiating upload of {filepath}: {e}")
+
+def periodic_backup():
+ while True:
+ time.sleep(1800)
+ try:
+ for key in DATA_FILES.keys():
+ upload_db_to_hf(key)
+ except Exception as e:
+ logging.error(f"Error during scheduled backup: {e}", exc_info=True)
+
+def find_item_by_field(data, field, value):
+ for item in data:
+ if isinstance(item, dict) and str(item.get(field)) == str(value):
+ return item
+ return None
+
+def find_user_by_pin(pin):
+ users = load_json_data('users')
+ return find_item_by_field(users, 'pin', pin)
+
+def format_currency_py(value):
+ try:
+ number = to_decimal(value)
+ return f"{number:,.2f}".replace(",", " ").replace(".", ",")
+ except (InvalidOperation, TypeError, ValueError):
+ return "0,00"
+
+def generate_receipt_html(transaction):
+ 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"""
+
+ | {item_name_with_size} |
+ {item['quantity']} |
+ {format_currency_py(item['price_at_sale'])} |
+ {format_currency_py(item.get('discount_per_item', '0'))} |
+ {format_currency_py(item['total'])} |
+
+ """
+
+ 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"""
+
+
+
+
+
+ Чек {transaction['id'][:8]}
+
+
+
+
+
Jigit_Shopping
+
--------------------------------
+
Чек №: {transaction['id'][:8]}
+
Дата: {datetime.fromisoformat(transaction['timestamp']).strftime('%d.%m.%Y %H:%M')}
+
Кассир: {transaction['user_name']}
+
Клиент: {transaction.get('client_name', 'Розничный покупатель')}
+
--------------------------------
+
+
+
+ | Товар |
+ Кол. |
+ Цена |
+ Скидка |
+ Сумма |
+
+
+ {items_html}
+
+
--------------------------------
+
Итого: {format_currency_py(transaction['total_amount'])} с
+
Способ оплаты: {payment_method_display}
+
--------------------------------
+
Спасибо за покупку!
+
+
+
+ """
+
+def admin_required(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if 'admin_logged_in' not in session:
+ flash("Для доступа к этой странице требуется аутентификация.", "warning")
+ return redirect(url_for('admin_login', next=request.url))
+ return f(*args, **kwargs)
+ return decorated_function
+
+@app.context_processor
+def inject_utils():
+ return {'format_currency_py': format_currency_py, 'get_current_time': get_current_time, 'quote': quote, 'SIZES': SIZES}
+
+@app.route('/')
+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)
+
+@app.route('/inventory', methods=['GET', 'POST'])
+@admin_required
+def inventory_management():
+ if request.method == 'POST':
+ try:
+ name = request.form.get('name', '').strip()
+ barcode = request.form.get('barcode', '').strip()
+
+ if not name or not barcode:
+ flash("Название и штрих-код - обязательные поля.", "danger")
+ return redirect(url_for('inventory_management'))
+
+ inventory = load_json_data('inventory')
+ if find_item_by_field(inventory, 'barcode', barcode):
+ flash(f"Товар со штрих-кодом {barcode} уже существует.", "warning")
+ return redirect(url_for('inventory_management'))
+
+ variants = []
+ variant_names = request.form.getlist('variant_name[]')
+ variant_prices = request.form.getlist('variant_price[]')
+ variant_cost_prices = request.form.getlist('variant_cost_price[]')
+ variant_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)
+
+@app.route('/inventory/edit/', methods=['POST'])
+@admin_required
+def edit_product(product_id):
+ inventory = load_json_data('inventory')
+ product_found = False
+ for i, product in enumerate(inventory):
+ if isinstance(product, dict) and product.get('id') == product_id:
+ try:
+ name = request.form.get('name', '').strip()
+ barcode = request.form.get('barcode', '').strip()
+
+ if not name or not barcode:
+ flash("Название и штрих-код обязательны.", "danger")
+ return redirect(url_for('inventory_management'))
+
+ existing_barcode = find_item_by_field(inventory, 'barcode', barcode)
+ if existing_barcode and existing_barcode.get('id') != product_id:
+ flash(f"Штрих-код {barcode} уже используется другим товаром.", "warning")
+ return redirect(url_for('inventory_management'))
+
+ inventory[i]['name'] = name
+ inventory[i]['barcode'] = barcode
+
+ new_variants = []
+ variant_ids = request.form.getlist('variant_id[]')
+ variant_names = request.form.getlist('variant_name[]')
+ variant_prices = request.form.getlist('variant_price[]')
+ variant_cost_prices = request.form.getlist('variant_cost_price[]')
+ variant_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'))
+
+@app.route('/inventory/delete/', methods=['POST'])
+@admin_required
+def delete_product(product_id):
+ inventory = load_json_data('inventory')
+ initial_len = len(inventory)
+ inventory = [p for p in inventory if not (isinstance(p, dict) and p.get('id') == product_id)]
+ if len(inventory) < initial_len:
+ save_json_data('inventory', inventory)
+ upload_db_to_hf('inventory')
+ flash("Товар удален.", "success")
+ else:
+ flash("Товар не найден.", "warning")
+ return redirect(url_for('inventory_management'))
+
+@app.route('/inventory/stock_in', methods=['POST'])
+@admin_required
+def stock_in():
+ try:
+ product_id = request.form.get('product_id')
+ variant_id = request.form.get('variant_id')
+ 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'))
+
+@app.route('/upload_image', methods=['POST'])
+@admin_required
+def upload_image():
+ if 'image' not in request.files:
+ return jsonify({'success': False, 'message': 'No file part'}), 400
+ 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
+
+@app.route('/api/product_by_barcode/')
+def get_product_by_barcode(barcode):
+ inventory = load_json_data('inventory')
+ product = find_item_by_field(inventory, 'barcode', barcode)
+ if product:
+ active_variants = [v for v in product.get('variants', []) if v.get('stock', 0) > 0]
+ if active_variants:
+ product_copy = product.copy()
+ product_copy['variants'] = active_variants
+ return jsonify({'success': True, 'product': product_copy})
+ else:
+ return jsonify({'success': False, 'message': 'Товар закончился на складе'}), 404
+ return jsonify({'success': False, 'message': 'Товар не найден'}), 404
+
+@app.route('/api/clients')
+def get_clients():
+ clients = load_json_data('clients')
+ return jsonify(list(clients.values()))
+
+@app.route('/api/client//transactions')
+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)
+
+@app.route('/api/complete_sale', methods=['POST'])
+def complete_sale():
+ try:
+ data = request.get_json()
+ cart = data.get('cart', {})
+ user_id = data.get('userId')
+ kassa_id = data.get('kassaId')
+ shift_id = data.get('shiftId')
+ 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
+
+@app.route('/receipt/')
+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="Чек не найден")
+
+@app.route('/transactions')
+@admin_required
+def transaction_history():
+ selected_date_str = request.args.get('date', get_current_time().strftime('%Y-%m-%d'))
+ selected_kassa_id = request.args.get('kassa', '')
+ try:
+ selected_date = datetime.strptime(selected_date_str, '%Y-%m-%d').date()
+ except ValueError:
+ selected_date = get_current_time().date()
+ selected_date_str = selected_date.strftime('%Y-%m-%d')
+
+ transactions = load_json_data('transactions')
+ kassas = load_json_data('kassas')
+
+ filtered_transactions = [
+ t for t in transactions
+ if datetime.fromisoformat(t['timestamp']).date() == selected_date
+ ]
+
+ if selected_kassa_id:
+ filtered_transactions = [
+ t for t in filtered_transactions
+ if t.get('kassa_id') == selected_kassa_id
+ ]
+
+ total_sales = sum(to_decimal(t['total_amount']) for t in filtered_transactions if t.get('type') == 'sale')
+
+ 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)
+
+@app.route('/admin/transaction/edit/', methods=['POST'])
+@admin_required
+def edit_transaction(transaction_id):
+ try:
+ data = request.get_json()
+ items_update = data.get('items', [])
+
+ transactions = load_json_data('transactions')
+ kassas = load_json_data('kassas')
+ 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
+
+@app.route('/admin/transaction/delete/', methods=['POST'])
+@admin_required
+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'))
+
+@app.route('/reports')
+@admin_required
+def reports():
+ today = get_current_time().date()
+ start_date_str = request.args.get('start_date', (today.replace(day=1)).strftime('%Y-%m-%d'))
+ end_date_str = request.args.get('end_date', (today).strftime('%Y-%m-%d'))
+
+ start_date = datetime.strptime(start_date_str, '%Y-%m-%d').replace(tzinfo=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)
+
+@app.route('/reports/product_roi')
+@admin_required
+def product_roi_report():
+ inventory = load_json_data('inventory')
+ transactions = load_json_data('transactions')
+
+ product_stats = []
+
+ for product in inventory:
+ for variant in product.get('variants', []):
+ total_revenue = Decimal('0.00')
+ total_cogs = Decimal('0.00')
+ total_qty_sold = 0
+
+ for t in transactions:
+ if t['type'] == '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)
+
+@app.route('/admin', methods=['GET'])
+@admin_required
+def admin_panel():
+ users = load_json_data('users')
+ kassas = load_json_data('kassas')
+ expenses = load_json_data('expenses')
+ personal_expenses = load_json_data('personal_expenses')
+ 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)
+
+@app.route('/admin/shifts')
+@admin_required
+def admin_shifts():
+ shifts = load_json_data('shifts')
+ shifts.sort(key=lambda x: x.get('start_time', ''), reverse=True)
+ html = BASE_TEMPLATE.replace('__TITLE__', "История смен").replace('__CONTENT__', SHIFTS_CONTENT)
+ return render_template_string(html, shifts=shifts)
+
+@app.route('/admin/user', methods=['POST'])
+@admin_required
+def manage_user():
+ action = request.form.get('action')
+ users = load_json_data('users')
+
+ if action == 'add':
+ name = request.form.get('name', '').strip()
+ pin = request.form.get('pin', '').strip()
+ payment_type = request.form.get('payment_type')
+ payment_value = to_decimal(request.form.get('payment_value', '0'))
+ if name and pin and pin.isdigit() and payment_type:
+ new_user = {
+ 'id': uuid.uuid4().hex, 'name': name, 'pin': pin,
+ 'payment_type': payment_type, 'payment_value': str(payment_value)
+ }
+ users.append(new_user)
+ flash(f"Кассир '{name}' добавлен.", "success")
+ else:
+ flash("��се поля обязательны.", "danger")
+
+ elif action == 'edit':
+ user_id = request.form.get('id')
+ user = find_item_by_field(users, 'id', user_id)
+ if user:
+ user['name'] = request.form.get('name', '').strip()
+ user['pin'] = request.form.get('pin', '').strip()
+ user['payment_type'] = request.form.get('payment_type')
+ user['payment_value'] = str(to_decimal(request.form.get('payment_value', '0')))
+ flash(f"Данные кассира '{user['name']}' обновлены.", "success")
+ else:
+ flash("Кассир не найден.", "warning")
+
+ elif action == 'delete':
+ user_id = request.form.get('id')
+ initial_len = len(users)
+ users = [u for u in users if u.get('id') != user_id]
+ if len(users) < initial_len:
+ flash("Кассир удален.", "success")
+ else:
+ flash("Кассир не найден.", "warning")
+
+ save_json_data('users', users)
+ upload_db_to_hf('users')
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/kassa', methods=['POST'])
+@admin_required
+def manage_kassa():
+ action = request.form.get('action')
+ kassas = load_json_data('kassas')
+
+ if action == 'add':
+ name = request.form.get('name', '').strip()
+ balance = to_decimal(request.form.get('balance', '0'))
+ if name:
+ new_kassa = {
+ 'id': uuid.uuid4().hex,
+ 'name': name,
+ 'balance': str(balance),
+ 'history': []
+ }
+ if balance > 0:
+ new_kassa['history'].append({
+ 'type': 'deposit',
+ 'amount': str(balance),
+ 'timestamp': get_current_time().isoformat(),
+ 'description': 'Начальный баланс'
+ })
+ kassas.append(new_kassa)
+ flash(f"Касса '{name}' добавлена.", "success")
+ else:
+ flash("Название кассы обязательно.", "danger")
+
+ elif action == 'delete':
+ kassa_id = request.form.get('id')
+ initial_len = len(kassas)
+ kassas = [k for k in kassas if k.get('id') != kassa_id]
+ if len(kassas) < initial_len:
+ flash("Касса удалена.", "success")
+ else:
+ flash("Касса не найдена.", "warning")
+
+ save_json_data('kassas', kassas)
+ upload_db_to_hf('kassas')
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/kassa_op', methods=['POST'])
+@admin_required
+def kassa_operation():
+ kassa_id = request.form.get('kassa_id')
+ op_type = request.form.get('op_type')
+ amount = to_decimal(request.form.get('amount', '0'))
+ description = request.form.get('description', '').strip()
+
+ if not kassa_id or not op_type or amount <= 0:
+ flash("Выберите кассу, тип операции и укажите положительную сумму.", "danger")
+ return redirect(url_for('admin_panel'))
+
+ kassas = load_json_data('kassas')
+ kassa_found = False
+ for i, kassa in enumerate(kassas):
+ if kassa.get('id') == kassa_id:
+ current_balance = to_decimal(kassa.get('balance', '0'))
+ new_balance = current_balance
+
+ if op_type == 'deposit':
+ new_balance += amount
+ elif op_type == 'withdrawal':
+ if amount > current_balance:
+ flash("Сумма изъятия превышает баланс кассы.", "danger")
+ return redirect(url_for('admin_panel'))
+ new_balance -= amount
+
+ kassas[i]['balance'] = str(new_balance)
+ if 'history' not in kassas[i]: kassas[i]['history'] = []
+ kassas[i]['history'].append({
+ 'type': op_type,
+ 'amount': str(amount),
+ 'timestamp': get_current_time().isoformat(),
+ 'description': description or f"{'Внесение' if op_type == 'deposit' else 'Изъятие'} средств"
+ })
+ kassa_found = True
+ break
+
+ if kassa_found:
+ save_json_data('kassas', kassas)
+ upload_db_to_hf('kassas')
+ flash("Операция по кассе успешно проведена.", "success")
+ else:
+ flash("Касса не найдена.", "danger")
+
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/expense', methods=['POST'])
+@admin_required
+def manage_expense():
+ amount = to_decimal(request.form.get('amount', '0'))
+ description = request.form.get('description', '').strip()
+
+ if amount <= 0 or not description:
+ flash("Укажите положительную сумму и описание расхода.", "danger")
+ return redirect(url_for('admin_panel'))
+
+ new_expense = {
+ 'id': uuid.uuid4().hex,
+ 'timestamp': get_current_time().isoformat(),
+ 'amount': str(amount),
+ 'description': description
+ }
+ expenses = load_json_data('expenses')
+ expenses.append(new_expense)
+ save_json_data('expenses', expenses)
+ upload_db_to_hf('expenses')
+ flash("Расход успешно добавлен.", "success")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/expense/delete/', methods=['POST'])
+@admin_required
+def delete_expense(expense_id):
+ expenses = load_json_data('expenses')
+ initial_len = len(expenses)
+ expenses = [e for e in expenses if e.get('id') != expense_id]
+ if len(expenses) < initial_len:
+ save_json_data('expenses', expenses)
+ upload_db_to_hf('expenses')
+ flash("Расход удален.", "success")
+ else:
+ flash("Расход не найден.", "warning")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/personal_expense', methods=['POST'])
+@admin_required
+def manage_personal_expense():
+ amount = to_decimal(request.form.get('amount', '0'))
+ description = request.form.get('description', '').strip()
+
+ if amount <= 0 or not description:
+ flash("Укажите положительную сумму и описание личного расхода.", "danger")
+ return redirect(url_for('admin_panel'))
+
+ new_expense = {
+ 'id': uuid.uuid4().hex,
+ 'timestamp': get_current_time().isoformat(),
+ 'amount': str(amount),
+ 'description': description
+ }
+ expenses = load_json_data('personal_expenses')
+ expenses.append(new_expense)
+ save_json_data('personal_expenses', expenses)
+ upload_db_to_hf('personal_expenses')
+ flash("Личный расход успешно добавлен.", "success")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin/personal_expense/delete/', methods=['POST'])
+@admin_required
+def delete_personal_expense(expense_id):
+ expenses = load_json_data('personal_expenses')
+ initial_len = len(expenses)
+ expenses = [e for e in expenses if e.get('id') != expense_id]
+ if len(expenses) < initial_len:
+ save_json_data('personal_expenses', expenses)
+ upload_db_to_hf('personal_expenses')
+ flash("Личный расход удален.", "success")
+ else:
+ flash("Личный расход не найден.", "warning")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/cashier_login', methods=['GET', 'POST'])
+def cashier_login():
+ if request.method == 'POST':
+ pin = request.form.get('pin')
+ user = find_user_by_pin(pin)
+ if user:
+ return redirect(url_for('cashier_dashboard', user_id=user['id']))
+ else:
+ flash("Неверный ПИН-код.", "danger")
+ html = BASE_TEMPLATE.replace('__TITLE__', "Вход для кассира").replace('__CONTENT__', CASHIER_LOGIN_CONTENT).replace('__SCRIPTS__', '')
+ return render_template_string(html)
+
+@app.route('/api/verify_pin', methods=['POST'])
+def verify_pin():
+ pin = request.json.get('pin')
+ user = find_user_by_pin(pin)
+ if user:
+ return jsonify({'success': True, 'user': {'id': user['id'], 'name': user['name']}})
+ else:
+ return jsonify({'success': False, 'message': 'Неверный ПИН-код'}), 401
+
+@app.route('/api/shift/start', methods=['POST'])
+def start_shift():
+ data = request.json
+ user_id = data.get('userId')
+ kassa_id = data.get('kassaId')
+
+ if not user_id:
+ return jsonify({'success': False, 'message': 'Missing user ID'}), 400
+
+ shifts = load_json_data('shifts')
+ open_shift = next((s for s in shifts if s.get('user_id') == user_id and s.get('end_time') is None), None)
+ if open_shift:
+ return jsonify({'success': True, 'shift': open_shift})
+
+ if not kassa_id:
+ return jsonify({'success': False, 'message': 'Выберите кассу для начала новой смены'}), 400
+
+ users = load_json_data('users')
+ kassas = load_json_data('kassas')
+ user = find_item_by_field(users, 'id', user_id)
+ kassa = find_item_by_field(kassas, 'id', kassa_id)
+
+ if not user or not kassa:
+ return jsonify({'success': False, 'message': 'Кассир или касса не найдены'}), 404
+
+ new_shift = {
+ 'id': uuid.uuid4().hex,
+ 'user_id': user_id,
+ 'user_name': user['name'],
+ 'kassa_id': kassa_id,
+ 'kassa_name': kassa['name'],
+ 'start_time': get_current_time().isoformat(),
+ 'start_balance': kassa.get('balance', '0'),
+ 'end_time': None
+ }
+ shifts.append(new_shift)
+ save_json_data('shifts', shifts)
+ upload_db_to_hf('shifts')
+ return jsonify({'success': True, 'shift': new_shift})
+
+@app.route('/api/shift/end', methods=['POST'])
+def end_shift():
+ data = request.json
+ shift_id = data.get('shiftId')
+ if not shift_id:
+ return jsonify({'success': False, 'message': 'Missing shift ID'}), 400
+
+ shifts = load_json_data('shifts')
+ kassas = load_json_data('kassas')
+ transactions = load_json_data('transactions')
+
+ shift_found = False
+ for i, shift in enumerate(shifts):
+ if shift.get('id') == shift_id:
+ if shift.get('end_time'):
+ return jsonify({'success': False, 'message': 'Смена уже закрыта'}), 400
+
+ shift['end_time'] = get_current_time().isoformat()
+
+ kassa = find_item_by_field(kassas, 'id', shift['kassa_id'])
+ shift['end_balance'] = kassa.get('balance', '0') if kassa else '0'
+
+ shift_transactions = [
+ t for t in transactions
+ if t.get('shift_id') == shift_id and datetime.fromisoformat(t['timestamp']) >= datetime.fromisoformat(shift['start_time'])
+ ]
+
+ cash_sales = sum(to_decimal(t['total_amount']) for t in shift_transactions if t['type'] == 'sale' and t['payment_method'] == 'cash')
+ card_sales = sum(to_decimal(t['total_amount']) for t in shift_transactions if t['type'] == 'sale' and t['payment_method'] == 'card')
+
+ shift['cash_sales'] = str(cash_sales)
+ shift['card_sales'] = str(card_sales)
+ shift['total_sales'] = str(cash_sales + card_sales)
+
+ shifts[i] = shift
+ shift_found = True
+ break
+
+ if not shift_found:
+ return jsonify({'success': False, 'message': 'Смена не найдена'}), 404
+
+ save_json_data('shifts', shifts)
+ upload_db_to_hf('shifts')
+ return jsonify({'success': True, 'message': 'Смена успешно закрыта'})
+
+@app.route('/cashier_dashboard/')
+def cashier_dashboard(user_id):
+ users = load_json_data('users')
+ user = find_item_by_field(users, 'id', user_id)
+ if not user:
+ abort(404, "Кассир не найден")
+
+ transactions = load_json_data('transactions')
+ user_transactions = [t for t in transactions if t.get('user_id') == user_id]
+ user_transactions.sort(key=lambda x: x['timestamp'], reverse=True)
+
+ html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {user['name']}").replace('__CONTENT__', CASHIER_DASHBOARD_CONTENT).replace('__SCRIPTS__', '')
+ return render_template_string(html, user=user, transactions=user_transactions)
+
+@app.route('/return_transaction/', methods=['POST'])
+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))
+
+@app.route('/clients')
+@admin_required
+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)
+
+@app.route('/admin/client', methods=['POST'])
+@admin_required
+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'))
+
+@app.route('/debts')
+@admin_required
+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)
+
+@app.route('/admin/debt_op', methods=['POST'])
+@admin_required
+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'))
+
+@app.route('/backup', methods=['POST'])
+@admin_required
+def backup_hf():
+ try:
+ for key in DATA_FILES.keys():
+ upload_db_to_hf(key)
+ flash(f"Резервное копирование {len(DATA_FILES)} файлов инициировано.", "success")
+ except Exception as e:
+ flash(f"Ошибка при резервном копировании: {e}", "danger")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/download', methods=['GET'])
+@admin_required
+def download_hf():
+ errors = []
+ success_count = 0
+ for key in DATA_FILES.keys():
+ filepath, _ = DATA_FILES[key]
+ filename = os.path.basename(filepath)
+ try:
+ hf_hub_download(
+ repo_id=REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN_READ,
+ local_dir=DATA_DIR, local_dir_use_symlinks=False, force_download=True,
+ )
+ success_count += 1
+ except Exception as e:
+ errors.append(f"Ошибка загрузки {filename}: {e}")
+ if success_count > 0:
+ flash(f"Успешно загружено {success_count} файлов. Данные перезаписаны.", "success")
+ if errors:
+ flash("Произошли ошибки: " + "; ".join(errors), "danger")
+ return redirect(url_for('admin_panel'))
+
+@app.route('/admin_login', methods=['GET', 'POST'])
+def admin_login():
+ if request.method == 'POST':
+ password = request.form.get('password')
+ if password == ADMIN_PASS:
+ session['admin_logged_in'] = True
+ session.permanent = True
+ next_url = request.args.get('next')
+ flash("Вы успешно вошли в систему.", "success")
+ return redirect(next_url or url_for('admin_panel'))
+ else:
+ flash("Неверный пароль.", "danger")
+ html = BASE_TEMPLATE.replace('__TITLE__', "Вход для администратора").replace('__CONTENT__', ADMIN_LOGIN_CONTENT).replace('__SCRIPTS__', '')
+ return render_template_string(html)
+
+@app.route('/admin_logout')
+def admin_logout():
+ session.pop('admin_logged_in', None)
+ flash("Вы вышли из системы.", "info")
+ return redirect(url_for('sales_screen'))
+
+BASE_TEMPLATE = """
+
+
+
+
+
+ __TITLE__ - POS
+
+
+
+
+
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+
+ {{ message }}
+
+
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+ __CONTENT__
+
+
+
+
+
+
+
+
+
Чек успешно создан.
+
+
+
+ +996
+
+
+
Введите номер без +996.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+__SCRIPTS__
+
+
+"""
+
+SALES_SCREEN_CONTENT = """
+
+
+
+
+
+
+
+
+
+ {% for letter, products in grouped_inventory %}
+
+
+
+
+ {% for p in products %}
+
+
+
{{ p.name }}
+
+ {% 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 %}
+
+
+
+ {% endfor %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Итого:
+ 0,00 с
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Кассир:
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+SALES_SCREEN_SCRIPTS = """
+
+"""
+
+INVENTORY_CONTENT = """
+
+
+
+
+
Единиц товара на складе
+ {{ inventory_summary.total_units }} шт.
+
+
+
+
+
+
+
Сумма по себестоимости
+ {{ format_currency_py(inventory_summary.total_cost_value) }} с
+
+
+
+
+
+
+
Потенциальная прибыль
+ {{ format_currency_py(inventory_summary.potential_profit) }} с
+
+
+
+
+
+
+
+
+
+
+
+ {% for p in inventory %}
+
+
+
+
+
+
+
+
+ {% for v in p.variants %}
+
+
+
+
+
+ {% for size in SIZES[:4] %}| {{size}} | {% endfor %}
+
+
+ {% for size in SIZES[:4] %}| {{ v.sizes.get(size, 0) }} | {% endfor %}
+
+
+ {% for size in SIZES[4:8] %}| {{size}} | {% endfor %}
+
+
+ {% for size in SIZES[4:8] %}| {{ v.sizes.get(size, 0) }} | {% endfor %}
+
+
+ {% for size in SIZES[8:] %}| {{size}} | {% endfor %}{% for i in range(4 - SIZES[8:]|length) %} | {% endfor %}
+
+
+ {% for size in SIZES[8:] %}| {{ v.sizes.get(size, 0) }} | {% endfor %}{% for i in range(4 - SIZES[8:]|length) %} | {% endfor %}
+
+
+
+
+ {% else %}
+
Нет вариантов
+ {% endfor %}
+
+
+
+ {% endfor %}
+
+
+
+
+{% for p in inventory %}
+
+{% endfor %}
+
+
+"""
+
+INVENTORY_SCRIPTS = """
+
+"""
+
+TRANSACTIONS_CONTENT = """
+
+
+
+
+ {% set selected_kassa = kassas|selectattr('id', 'equalto', selected_kassa_id)|first %}
+
Общая выручка за {{ selected_date }}{% if selected_kassa %} (Касса: {{ selected_kassa.name }}){% endif %}: {{ format_currency_py(total_sales) }} с
+
+
+
+
+
+
+ | ID | Дата | Тип | Кассир/Клиент | Касса | Сумма | Статус | Позиции | |
+
+ {% for t in transactions %}
+
+ | {{ t.id[:8] }} |
+ {{ t.timestamp[11:16] }} |
+
+ {% if t.type == 'sale' %}
+ {% if t.payment_method == 'cash' %}Наличные
+ {% elif t.payment_method == 'card' %}Карта
+ {% elif t.payment_method == 'debt' %}В долг
+ {% endif %}
+ {% elif t.type == 'return' %}Возврат
+ {% endif %}
+ |
+ {{ t.user_name }} {{ t.get('client_name') }} | {{ t.kassa_name }} |
+ {{ format_currency_py(t.total_amount) }} с |
+ {{t.status}} |
+
+
+ {% for item in t['items'] %}- {{ 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 %})
{% endfor %}
+
+ |
+
+ {% if session.admin_logged_in %}
+ {% if t.type == 'sale' %}
+
+ {% endif %}
+
+ {% endif %}
+ |
+
+ {% else %}
+ | Нет транзакций за выбранную дату. |
+ {% endfor %}
+
+
+
+
+
+
+
+"""
+
+TRANSACTIONS_SCRIPTS = """
+
+"""
+
+REPORTS_CONTENT = """
+
+
+
+
+
+
+
+
+ - Выручка (за вычетом возвратов) {{ format_currency_py(stats.total_revenue) }} с
+ - Себестоимость проданных товаров {{ format_currency_py(stats.total_cogs) }} с
+ - Валовая прибыль {{ format_currency_py(stats.gross_profit) }} с
+ - Расходы (операционные) -{{ format_currency_py(stats.total_expenses) }} с
+ - Расходы (личные) -{{ format_currency_py(stats.total_personal_expenses) }} с
+ - Расходы (зарплаты) -{{ format_currency_py(stats.total_salary_expenses) }} с
+ - Чистая прибыль {{ format_currency_py(stats.net_profit) }} с
+
+
+
+
+
+
+
+ | Кассир | К выплате |
+
+ {% for name, payout in stats.cashier_payouts %}
+ | {{ name }} | {{ format_currency_py(payout) }} с |
+ {% else %}| Нет данных для расчета. |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+ | Кассир | Чеков | Сумма |
+
+ {% for name, data in stats.sales_by_cashier %}
+ | {{ name }} | {{ data.count }} | {{ format_currency_py(data.total) }} с |
+ {% else %}| Нет продаж за выбранный период. |
+ {% endfor %}
+
+
+
+
+
+
+
+
+ | Дата | Описание | Сумма |
+
+ {% for expense in expenses %}
+
+ | {{ expense.timestamp[:10] }} |
+ {{ expense.description }} |
+ {{ format_currency_py(expense.amount) }} с |
+
+ {% else %}| Нет расходов за выбранный период. |
+ {% endfor %}
+
+
+
+
+
+
+
+
+ | Дата | Описание | Сумма |
+
+ {% for expense in personal_expenses %}
+
+ | {{ expense.timestamp[:10] }} |
+ {{ expense.description }} |
+ {{ format_currency_py(expense.amount) }} с |
+
+ {% else %}| Нет личных расходов за выбранный период. |
+ {% endfor %}
+
+
+
+
+
+
+"""
+
+REPORTS_SCRIPTS = ""
+
+PRODUCT_ROI_CONTENT = """
+
+
+
+
+
+
+
+ | Товар (вариант) |
+ Продано, шт |
+ Выручка |
+ Стоимость на складе |
+ Общие вложения |
+ Результат (Окупаемость) |
+
+
+
+ {% for item in stats %}
+
+ {{ item.name }} {{ item.variant_name }} |
+ {{ item.total_qty_sold }} |
+ {{ format_currency_py(item.total_revenue) }} с |
+ {{ format_currency_py(item.inventory_value) }} с |
+ {{ format_currency_py(item.total_investment) }} с |
+
+ {{ format_currency_py(item.payback) }} с
+ |
+
+ {% else %}
+ | Нет данных для анализа. |
+ {% endfor %}
+
+
+
+
+
+
+"""
+
+ADMIN_CONTENT = """
+
+
+
+
+
+
+
+
+
+
+
Все расходы:
+
+
+ | Дата | Описание | Сумма | |
+
+ {% for e in expenses %}
+
+ | {{ e.timestamp[:10] }} |
+ {{ e.description }} |
+ {{ format_currency_py(e.amount) }} с |
+
+
+ |
+
+ {% else %}
+ | Расходов пока нет |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
Все расходы:
+
+
+ | Дата | Описание | Сумма | |
+
+ {% for e in personal_expenses %}
+
+ | {{ e.timestamp[:10] }} |
+ {{ e.description }} |
+ {{ format_currency_py(e.amount) }} с |
+
+
+ |
+
+ {% else %}
+ | Расходов пока нет |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
Данные периодически сохраняются в облако. Можно сделать это вручную.
+
+
+
+
+
+
+
+
+{% for u in users %}
+
+{% endfor %}
+"""
+
+ADMIN_SCRIPTS = ""
+
+SHIFTS_CONTENT = """
+
+
+
+
+
+
+
+ | Кассир |
+ Касса |
+ Начало смены |
+ Конец смены |
+ Продажи (наличные) |
+ Продажи (карта) |
+ Итого продаж |
+
+
+
+ {% for s in shifts %}
+
+ | {{ s.user_name }} |
+ {{ s.kassa_name }} |
+ {{ s.start_time[:16]|replace('T', ' ') }} |
+ {{ (s.end_time[:16]|replace('T', ' ')) if s.end_time else 'Активна' }} |
+ {{ format_currency_py(s.get('cash_sales', 0)) }} с |
+ {{ format_currency_py(s.get('card_sales', 0)) }} с |
+ {{ format_currency_py(s.get('total_sales', 0)) }} с |
+
+ {% else %}
+ | Нет данных о сменах. |
+ {% endfor %}
+
+
+
+
+
+"""
+
+ADMIN_LOGIN_CONTENT = """
+
+
+
+
+
Вход для администратора
+
+
+
+
+
+"""
+
+CASHIER_LOGIN_CONTENT = """
+
+"""
+
+CASHIER_DASHBOARD_CONTENT = """
+
+
+
+
+ | ID | Дата | Тип | Сумма | Статус | Действие |
+
+ {% for t in transactions %}
+
+ | {{ t.id[:8] }} |
+ {{ t.timestamp[:16]|replace('T', ' ') }} |
+ {{'Продажа' if t.type == 'sale' else 'Возврат'}} |
+ {{ format_currency_py(t.total_amount) }} с |
+ {{t.status}} |
+
+ {% if t.type == 'sale' and t.status == 'completed' %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+"""
+
+CLIENTS_CONTENT = """
+
+
+
+
+
+
+ | Имя | Телефон | Долг | |
+
+ {% for client in clients %}
+
+ | {{ client.name }} |
+ {{ client.phone }} |
+ {{ format_currency_py(client.debt) }} с |
+
+
+
+
+
+ |
+
+ {% else %}
+ | Клиенты не найдены. |
+ {% endfor %}
+
+
+
+
+
+
+
+{% for client in clients %}
+
+
+{% endfor %}
+"""
+
+CLIENTS_SCRIPTS = """
+
+"""
+
+DEBTS_CONTENT = """
+
+
+
+
+
+
+ | Клиент | Сумма долга |
+
+ {% for debtor in debtors %}
+
+ | {{ debtor.name }} |
+ {{ format_currency_py(debtor.debt) }} с |
+
+ {% else %}
+ | Нет должников. |
+ {% endfor %}
+
+
+
+
+
+"""
+
+DEBTS_SCRIPTS = """
+
+"""
+
+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)
+