|
|
|
|
|
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, abort, Response, session |
|
|
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", "admin") |
|
|
|
|
|
DATA_DIR = 'pos_data' |
|
|
os.makedirs(DATA_DIR, 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') |
|
|
|
|
|
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()), |
|
|
} |
|
|
|
|
|
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/postest" |
|
|
|
|
|
BISHKEK_TZ = pytz.timezone('Asia/Bishkek') |
|
|
|
|
|
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 [] |
|
|
|
|
|
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 item.get(field) == 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']: |
|
|
items_html += f""" |
|
|
<tr> |
|
|
<td>{item['name']}</td> |
|
|
<td style="text-align: right;">{item['quantity']}</td> |
|
|
<td style="text-align: right;">{format_currency_py(item['price_at_sale'])}</td> |
|
|
<td style="text-align: right;">{format_currency_py(item.get('discount_per_item', '0'))}</td> |
|
|
<td style="text-align: right;">{format_currency_py(item['total'])}</td> |
|
|
</tr> |
|
|
""" |
|
|
return f""" |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Чек {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>Кербен , 9 проход , 09 контейнер </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>--------------------------------</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>Способ оплаты: {'Наличные' if transaction['payment_method'] == 'cash' else 'Карта'}</p> |
|
|
<p>--------------------------------</p> |
|
|
<p>Спасибо за покупку!</p> |
|
|
</div> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
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} |
|
|
|
|
|
@app.route('/') |
|
|
def sales_screen(): |
|
|
inventory = load_json_data('inventory') |
|
|
users = load_json_data('users') |
|
|
kassas = load_json_data('kassas') |
|
|
|
|
|
active_inventory = [] |
|
|
for p in inventory: |
|
|
if isinstance(p, dict) and any(v.get('stock', 0) > 0 for v in p.get('variants', [])): |
|
|
active_inventory.append(p) |
|
|
|
|
|
active_inventory.sort(key=lambda x: x.get('name', '').lower()) |
|
|
|
|
|
grouped_inventory = defaultdict(list) |
|
|
for p in active_inventory: |
|
|
first_letter = p.get('name', '#')[0].upper() |
|
|
grouped_inventory[first_letter].append(p) |
|
|
|
|
|
sorted_grouped_inventory = sorted(grouped_inventory.items()) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Касса").replace('__CONTENT__', SALES_SCREEN_CONTENT).replace('__SCRIPTS__', SALES_SCREEN_SCRIPTS) |
|
|
return render_template_string(html, inventory=active_inventory, users=users, kassas=kassas, 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_stocks = request.form.getlist('variant_stock[]') |
|
|
|
|
|
for i in range(len(variant_names)): |
|
|
v_name = variant_names[i].strip() |
|
|
if not v_name: continue |
|
|
variants.append({ |
|
|
'id': uuid.uuid4().hex, |
|
|
'option_name': "Вариант", |
|
|
'option_value': v_name, |
|
|
'price': str(to_decimal(variant_prices[i])), |
|
|
'cost_price': str(to_decimal(variant_cost_prices[i])), |
|
|
'stock': int(to_decimal(variant_stocks[i], '0')), |
|
|
}) |
|
|
|
|
|
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()) |
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Склад").replace('__CONTENT__', INVENTORY_CONTENT).replace('__SCRIPTS__', INVENTORY_SCRIPTS) |
|
|
return render_template_string(html, inventory=inventory_list) |
|
|
|
|
|
@app.route('/inventory/edit/<product_id>', methods=['POST']) |
|
|
@admin_required |
|
|
def edit_product(product_id): |
|
|
inventory = load_json_data('inventory') |
|
|
product_found = False |
|
|
for i, product in enumerate(inventory): |
|
|
if isinstance(product, dict) and product.get('id') == product_id: |
|
|
try: |
|
|
name = request.form.get('name', '').strip() |
|
|
barcode = request.form.get('barcode', '').strip() |
|
|
|
|
|
if not name or not barcode: |
|
|
flash("Название и штрих-код обязательны.", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
existing_barcode = find_item_by_field(inventory, 'barcode', barcode) |
|
|
if existing_barcode and existing_barcode.get('id') != product_id: |
|
|
flash(f"Штрих-код {barcode} уже используется другим товаром.", "warning") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
inventory[i]['name'] = name |
|
|
inventory[i]['barcode'] = barcode |
|
|
|
|
|
new_variants = [] |
|
|
variant_ids = request.form.getlist('variant_id[]') |
|
|
variant_names = request.form.getlist('variant_name[]') |
|
|
variant_prices = request.form.getlist('variant_price[]') |
|
|
variant_cost_prices = request.form.getlist('variant_cost_price[]') |
|
|
variant_stocks = request.form.getlist('variant_stock[]') |
|
|
|
|
|
for j in range(len(variant_ids)): |
|
|
v_name = variant_names[j].strip() |
|
|
if not v_name: continue |
|
|
new_variants.append({ |
|
|
'id': variant_ids[j] or uuid.uuid4().hex, |
|
|
'option_name': "Вариант", |
|
|
'option_value': v_name, |
|
|
'price': str(to_decimal(variant_prices[j])), |
|
|
'cost_price': str(to_decimal(variant_cost_prices[j])), |
|
|
'stock': int(to_decimal(variant_stocks[j], '0')), |
|
|
}) |
|
|
|
|
|
inventory[i]['variants'] = new_variants |
|
|
inventory[i]['timestamp_updated'] = get_current_time().isoformat() |
|
|
product_found = True |
|
|
break |
|
|
except Exception as e: |
|
|
logging.error(f"Error updating product: {e}", exc_info=True) |
|
|
flash(f"Ошибка при обновлении товара: {e}", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
if product_found: |
|
|
save_json_data('inventory', inventory) |
|
|
upload_db_to_hf('inventory') |
|
|
flash("Товар успешно обновлен.", "success") |
|
|
else: |
|
|
flash("Товар не найден.", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
@app.route('/inventory/delete/<product_id>', methods=['POST']) |
|
|
@admin_required |
|
|
def delete_product(product_id): |
|
|
inventory = load_json_data('inventory') |
|
|
initial_len = len(inventory) |
|
|
inventory = [p for p in inventory if not (isinstance(p, dict) and p.get('id') == product_id)] |
|
|
if len(inventory) < initial_len: |
|
|
save_json_data('inventory', inventory) |
|
|
upload_db_to_hf('inventory') |
|
|
flash("Товар удален.", "success") |
|
|
else: |
|
|
flash("Товар не найден.", "warning") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
@app.route('/inventory/stock_in', methods=['POST']) |
|
|
@admin_required |
|
|
def stock_in(): |
|
|
try: |
|
|
product_id = request.form.get('product_id') |
|
|
variant_id = request.form.get('variant_id') |
|
|
quantity = int(request.form.get('quantity', 0)) |
|
|
cost_price_str = request.form.get('cost_price') |
|
|
delivery_cost = to_decimal(request.form.get('delivery_cost', '0')) |
|
|
|
|
|
if not product_id or not variant_id or quantity <= 0: |
|
|
flash("Неверные данные для оприходования.", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
inventory = load_json_data('inventory') |
|
|
product = find_item_by_field(inventory, 'id', product_id) |
|
|
if not product: |
|
|
flash("Товар не найден.", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
variant_found = False |
|
|
variant_name_for_log = "" |
|
|
for i, variant in enumerate(product.get('variants', [])): |
|
|
if variant.get('id') == variant_id: |
|
|
variant_name_for_log = variant.get('option_value', '') |
|
|
|
|
|
old_stock = variant.get('stock', 0) |
|
|
variant['stock'] = old_stock + quantity |
|
|
|
|
|
old_cost = to_decimal(variant.get('cost_price', '0')) |
|
|
new_cost = to_decimal(cost_price_str) if cost_price_str else old_cost |
|
|
|
|
|
if old_stock + quantity > 0: |
|
|
avg_cost = ((old_cost * old_stock) + (new_cost * quantity) + delivery_cost) / (old_stock + quantity) |
|
|
variant['cost_price'] = str(avg_cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) |
|
|
|
|
|
variant_found = True |
|
|
break |
|
|
|
|
|
if not variant_found: |
|
|
flash("Вариант товара не найден.", "danger") |
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
if delivery_cost > 0: |
|
|
expenses = load_json_data('expenses') |
|
|
new_expense = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'timestamp': get_current_time().isoformat(), |
|
|
'amount': str(delivery_cost), |
|
|
'description': f"Дорога: {product['name']} ({variant_name_for_log})" |
|
|
} |
|
|
expenses.append(new_expense) |
|
|
save_json_data('expenses', expenses) |
|
|
upload_db_to_hf('expenses') |
|
|
|
|
|
product['timestamp_updated'] = get_current_time().isoformat() |
|
|
save_json_data('inventory', inventory) |
|
|
upload_db_to_hf('inventory') |
|
|
flash(f"Остаток товара '{product['name']} ({variant_name_for_log})' увеличен на {quantity}.", "success") |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error stocking in: {e}", exc_info=True) |
|
|
flash(f"Ошибка при оприходовании: {e}", "danger") |
|
|
|
|
|
return redirect(url_for('inventory_management')) |
|
|
|
|
|
|
|
|
@app.route('/api/product_by_barcode/<barcode>') |
|
|
def get_product_by_barcode(barcode): |
|
|
inventory = load_json_data('inventory') |
|
|
product = find_item_by_field(inventory, 'barcode', barcode) |
|
|
if product: |
|
|
active_variants = [v for v in product.get('variants', []) if v.get('stock', 0) > 0] |
|
|
if active_variants: |
|
|
product_copy = product.copy() |
|
|
product_copy['variants'] = active_variants |
|
|
return jsonify({'success': True, 'product': product_copy}) |
|
|
else: |
|
|
return jsonify({'success': False, 'message': 'Товар закончился на складе'}), 404 |
|
|
return jsonify({'success': False, 'message': 'Товар не найден'}), 404 |
|
|
|
|
|
@app.route('/api/complete_sale', methods=['POST']) |
|
|
def complete_sale(): |
|
|
try: |
|
|
data = request.get_json() |
|
|
cart = data.get('cart', {}) |
|
|
user_id = data.get('userId') |
|
|
kassa_id = data.get('kassaId') |
|
|
payment_method = data.get('paymentMethod', 'cash') |
|
|
|
|
|
if not cart or not user_id or not kassa_id: |
|
|
return jsonify({'success': False, 'message': 'Неполные данные для продажи.'}), 400 |
|
|
|
|
|
inventory = load_json_data('inventory') |
|
|
users = load_json_data('users') |
|
|
kassas = load_json_data('kassas') |
|
|
|
|
|
user = find_item_by_field(users, 'id', user_id) |
|
|
kassa = find_item_by_field(kassas, 'id', kassa_id) |
|
|
|
|
|
if not user or not kassa: |
|
|
return jsonify({'success': False, 'message': 'Кассир или касса не найдены.'}), 404 |
|
|
|
|
|
sale_items = [] |
|
|
total_amount = Decimal('0.00') |
|
|
inventory_updates = {} |
|
|
|
|
|
for variant_id, cart_item in cart.items(): |
|
|
product = find_item_by_field(inventory, 'id', cart_item['productId']) |
|
|
if not product: |
|
|
return jsonify({'success': False, 'message': f"Товар с ID {cart_item['productId']} не найден."}), 404 |
|
|
|
|
|
variant = find_item_by_field(product.get('variants', []), 'id', variant_id) |
|
|
if not variant: |
|
|
return jsonify({'success': False, 'message': f"Вариант товара с ID {variant_id} не найден."}), 404 |
|
|
|
|
|
quantity_sold = cart_item['quantity'] |
|
|
current_stock = variant.get('stock', 0) |
|
|
|
|
|
if quantity_sold > current_stock: |
|
|
return jsonify({'success': False, 'message': f"Недостаточно товара '{product['name']} ({variant['option_value']})'. В наличии: {current_stock}"}), 400 |
|
|
|
|
|
price_at_sale = to_decimal(variant.get('price', '0')) |
|
|
cost_price_at_sale = to_decimal(variant.get('cost_price', '0')) |
|
|
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, |
|
|
'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) |
|
|
}) |
|
|
inventory_updates[variant_id] = {'product_id': product['id'], 'new_stock': current_stock - 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'), |
|
|
'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 variant_id, update_info in inventory_updates.items(): |
|
|
for p in inventory: |
|
|
if p.get('id') == update_info['product_id']: |
|
|
for v in p.get('variants', []): |
|
|
if v.get('id') == variant_id: |
|
|
v['stock'] = update_info['new_stock'] |
|
|
p['timestamp_updated'] = now_iso |
|
|
break |
|
|
break |
|
|
|
|
|
if payment_method == 'cash': |
|
|
for i, k in enumerate(kassas): |
|
|
if k.get('id') == kassa_id: |
|
|
current_balance = to_decimal(k.get('balance', '0')) |
|
|
kassas[i]['balance'] = str(current_balance + total_amount) |
|
|
if 'history' not in kassas[i] or not isinstance(kassas[i]['history'], list): |
|
|
kassas[i]['history'] = [] |
|
|
kassas[i]['history'].append({ |
|
|
'type': 'sale', |
|
|
'amount': str(total_amount), |
|
|
'timestamp': now_iso, |
|
|
'transaction_id': new_transaction['id'] |
|
|
}) |
|
|
break |
|
|
|
|
|
save_json_data('transactions', transactions) |
|
|
save_json_data('inventory', inventory) |
|
|
save_json_data('kassas', kassas) |
|
|
|
|
|
upload_db_to_hf('transactions') |
|
|
upload_db_to_hf('inventory') |
|
|
upload_db_to_hf('kassas') |
|
|
|
|
|
receipt_url = url_for('view_receipt', transaction_id=new_transaction['id'], _external=True) |
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': 'Продажа успешно зарегистрирована.', |
|
|
'transactionId': new_transaction['id'], |
|
|
'receiptUrl': receipt_url |
|
|
}) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error completing sale: {e}", exc_info=True) |
|
|
return jsonify({'success': False, 'message': f'Внутренняя ошибка сервера: {e}'}), 500 |
|
|
|
|
|
@app.route('/receipt/<transaction_id>') |
|
|
def view_receipt(transaction_id): |
|
|
transactions = load_json_data('transactions') |
|
|
transaction = find_item_by_field(transactions, 'id', transaction_id) |
|
|
if transaction and 'receipt_html' in transaction: |
|
|
return Response(transaction['receipt_html'], mimetype='text/html') |
|
|
abort(404, description="Чек не найден") |
|
|
|
|
|
@app.route('/transactions') |
|
|
def transaction_history(): |
|
|
selected_date_str = request.args.get('date', get_current_time().strftime('%Y-%m-%d')) |
|
|
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') |
|
|
|
|
|
filtered_transactions = [ |
|
|
t for t in transactions |
|
|
if datetime.fromisoformat(t['timestamp']).date() == selected_date |
|
|
] |
|
|
|
|
|
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) |
|
|
|
|
|
@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') |
|
|
users = load_json_data('users') |
|
|
|
|
|
filtered_transactions = [ |
|
|
t for t in transactions |
|
|
if start_date <= datetime.fromisoformat(t['timestamp']) < end_date |
|
|
] |
|
|
filtered_expenses = [ |
|
|
e for e in expenses |
|
|
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date |
|
|
] |
|
|
|
|
|
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) |
|
|
|
|
|
sales_by_cashier = defaultdict(lambda: {'count': 0, 'total': Decimal(0)}) |
|
|
for t in filtered_transactions: |
|
|
if t.get('type') == 'sale': |
|
|
cashier_name = t.get('user_name', 'Неизвестный') |
|
|
sales_by_cashier[cashier_name]['count'] += 1 |
|
|
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount']) |
|
|
elif t.get('type') == 'return': |
|
|
cashier_name = t.get('user_name', 'Неизвестный') |
|
|
sales_by_cashier[cashier_name]['count'] -= 1 |
|
|
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount']) |
|
|
|
|
|
cashier_payouts = defaultdict(Decimal) |
|
|
for t in filtered_transactions: |
|
|
user = find_item_by_field(users, 'id', t.get('user_id')) |
|
|
if user: |
|
|
payment_type = user.get('payment_type') |
|
|
payment_value = to_decimal(user.get('payment_value', '0')) |
|
|
if payment_type == 'percentage' and payment_value > 0: |
|
|
payout = to_decimal(t['total_amount']) * (payment_value / Decimal(100)) |
|
|
cashier_payouts[user['name']] += payout |
|
|
|
|
|
for user in users: |
|
|
if user.get('payment_type') == 'salary': |
|
|
monthly_salary = to_decimal(user.get('payment_value', '0')) |
|
|
if monthly_salary > 0: |
|
|
daily_salary = monthly_salary / Decimal(30) |
|
|
period_salary = daily_salary * Decimal(num_days) |
|
|
cashier_payouts[user['name']] += period_salary |
|
|
|
|
|
total_salary_expenses = sum(cashier_payouts.values()) |
|
|
net_profit = gross_profit - total_expenses - total_salary_expenses |
|
|
|
|
|
stats = { |
|
|
'total_revenue': total_revenue, |
|
|
'total_cogs': total_cogs, |
|
|
'gross_profit': gross_profit, |
|
|
'total_expenses': total_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) |
|
|
|
|
|
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) |
|
|
|
|
|
@app.route('/reports/product_roi') |
|
|
@admin_required |
|
|
def product_roi_report(): |
|
|
inventory = load_json_data('inventory') |
|
|
transactions = load_json_data('transactions') |
|
|
|
|
|
product_stats = [] |
|
|
|
|
|
for product in inventory: |
|
|
for variant in product.get('variants', []): |
|
|
total_revenue = Decimal('0.00') |
|
|
total_cogs = Decimal('0.00') |
|
|
total_qty_sold = 0 |
|
|
|
|
|
for t in transactions: |
|
|
if t['type'] in ['sale', 'return']: |
|
|
for item in t['items']: |
|
|
if item.get('variant_id') == variant['id']: |
|
|
total_revenue += to_decimal(item['total']) |
|
|
total_cogs += to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity'])) |
|
|
if t['type'] == 'sale': |
|
|
total_qty_sold += item['quantity'] |
|
|
elif t['type'] == 'return': |
|
|
total_qty_sold -= item['quantity'] |
|
|
|
|
|
current_stock = to_decimal(str(variant.get('stock', 0))) |
|
|
cost_price = to_decimal(variant.get('cost_price', '0')) |
|
|
|
|
|
inventory_value = current_stock * cost_price |
|
|
|
|
|
|
|
|
|
|
|
total_investment = total_cogs + inventory_value |
|
|
payback = total_revenue - total_investment |
|
|
|
|
|
product_stats.append({ |
|
|
'name': product['name'], |
|
|
'variant_name': variant['option_value'], |
|
|
'total_qty_sold': total_qty_sold, |
|
|
'total_revenue': total_revenue, |
|
|
'total_investment': total_investment, |
|
|
'inventory_value': inventory_value, |
|
|
'payback': payback |
|
|
}) |
|
|
|
|
|
product_stats.sort(key=lambda x: x['payback'], reverse=True) |
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Отчет по окупаемости товаров").replace('__CONTENT__', PRODUCT_ROI_CONTENT).replace('__SCRIPTS__', '') |
|
|
return render_template_string(html, stats=product_stats) |
|
|
|
|
|
@app.route('/admin', methods=['GET']) |
|
|
@admin_required |
|
|
def admin_panel(): |
|
|
users = load_json_data('users') |
|
|
kassas = load_json_data('kassas') |
|
|
expenses = load_json_data('expenses') |
|
|
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) |
|
|
|
|
|
@app.route('/admin/user', methods=['POST']) |
|
|
@admin_required |
|
|
def manage_user(): |
|
|
action = request.form.get('action') |
|
|
users = load_json_data('users') |
|
|
|
|
|
if action == 'add': |
|
|
name = request.form.get('name', '').strip() |
|
|
pin = request.form.get('pin', '').strip() |
|
|
payment_type = request.form.get('payment_type') |
|
|
payment_value = to_decimal(request.form.get('payment_value', '0')) |
|
|
if name and pin and pin.isdigit() and payment_type: |
|
|
new_user = { |
|
|
'id': uuid.uuid4().hex, 'name': name, 'pin': pin, |
|
|
'payment_type': payment_type, 'payment_value': str(payment_value) |
|
|
} |
|
|
users.append(new_user) |
|
|
flash(f"Кассир '{name}' добавлен.", "success") |
|
|
else: |
|
|
flash("Все поля обязательны.", "danger") |
|
|
|
|
|
elif action == 'edit': |
|
|
user_id = request.form.get('id') |
|
|
user = find_item_by_field(users, 'id', user_id) |
|
|
if user: |
|
|
user['name'] = request.form.get('name', '').strip() |
|
|
user['pin'] = request.form.get('pin', '').strip() |
|
|
user['payment_type'] = request.form.get('payment_type') |
|
|
user['payment_value'] = str(to_decimal(request.form.get('payment_value', '0'))) |
|
|
flash(f"Данные кассира '{user['name']}' обновлены.", "success") |
|
|
else: |
|
|
flash("Кассир не найден.", "warning") |
|
|
|
|
|
elif action == 'delete': |
|
|
user_id = request.form.get('id') |
|
|
initial_len = len(users) |
|
|
users = [u for u in users if u.get('id') != user_id] |
|
|
if len(users) < initial_len: |
|
|
flash("Кассир удален.", "success") |
|
|
else: |
|
|
flash("Кассир не найден.", "warning") |
|
|
|
|
|
save_json_data('users', users) |
|
|
upload_db_to_hf('users') |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/kassa', methods=['POST']) |
|
|
@admin_required |
|
|
def manage_kassa(): |
|
|
action = request.form.get('action') |
|
|
kassas = load_json_data('kassas') |
|
|
|
|
|
if action == 'add': |
|
|
name = request.form.get('name', '').strip() |
|
|
balance = to_decimal(request.form.get('balance', '0')) |
|
|
if name: |
|
|
new_kassa = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'name': name, |
|
|
'balance': str(balance), |
|
|
'history': [] |
|
|
} |
|
|
if balance > 0: |
|
|
new_kassa['history'].append({ |
|
|
'type': 'deposit', |
|
|
'amount': str(balance), |
|
|
'timestamp': get_current_time().isoformat(), |
|
|
'description': 'Начальный баланс' |
|
|
}) |
|
|
kassas.append(new_kassa) |
|
|
flash(f"Касса '{name}' добавлена.", "success") |
|
|
else: |
|
|
flash("Название кассы обязательно.", "danger") |
|
|
|
|
|
elif action == 'delete': |
|
|
kassa_id = request.form.get('id') |
|
|
initial_len = len(kassas) |
|
|
kassas = [k for k in kassas if k.get('id') != kassa_id] |
|
|
if len(kassas) < initial_len: |
|
|
flash("Касса удалена.", "success") |
|
|
else: |
|
|
flash("Касса не найдена.", "warning") |
|
|
|
|
|
save_json_data('kassas', kassas) |
|
|
upload_db_to_hf('kassas') |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/kassa_op', methods=['POST']) |
|
|
@admin_required |
|
|
def kassa_operation(): |
|
|
kassa_id = request.form.get('kassa_id') |
|
|
op_type = request.form.get('op_type') |
|
|
amount = to_decimal(request.form.get('amount', '0')) |
|
|
description = request.form.get('description', '').strip() |
|
|
|
|
|
if not kassa_id or not op_type or amount <= 0: |
|
|
flash("Выберите кассу, тип операции и укажите положительную сумму.", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
kassas = load_json_data('kassas') |
|
|
kassa_found = False |
|
|
for i, kassa in enumerate(kassas): |
|
|
if kassa.get('id') == kassa_id: |
|
|
current_balance = to_decimal(kassa.get('balance', '0')) |
|
|
new_balance = current_balance |
|
|
|
|
|
if op_type == 'deposit': |
|
|
new_balance += amount |
|
|
elif op_type == 'withdrawal': |
|
|
if amount > current_balance: |
|
|
flash("Сумма изъятия превышает баланс кассы.", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
new_balance -= amount |
|
|
|
|
|
kassas[i]['balance'] = str(new_balance) |
|
|
if 'history' not in kassas[i]: kassas[i]['history'] = [] |
|
|
kassas[i]['history'].append({ |
|
|
'type': op_type, |
|
|
'amount': str(amount), |
|
|
'timestamp': get_current_time().isoformat(), |
|
|
'description': description or f"{'Внесение' if op_type == 'deposit' else 'Изъятие'} средств" |
|
|
}) |
|
|
kassa_found = True |
|
|
break |
|
|
|
|
|
if kassa_found: |
|
|
save_json_data('kassas', kassas) |
|
|
upload_db_to_hf('kassas') |
|
|
flash("Операция по кассе успешно проведена.", "success") |
|
|
else: |
|
|
flash("Касса не найдена.", "danger") |
|
|
|
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/expense', methods=['POST']) |
|
|
@admin_required |
|
|
def manage_expense(): |
|
|
amount = to_decimal(request.form.get('amount', '0')) |
|
|
description = request.form.get('description', '').strip() |
|
|
|
|
|
if amount <= 0 or not description: |
|
|
flash("Укажите положительную сумму и описание расхода.", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
new_expense = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'timestamp': get_current_time().isoformat(), |
|
|
'amount': str(amount), |
|
|
'description': description |
|
|
} |
|
|
expenses = load_json_data('expenses') |
|
|
expenses.append(new_expense) |
|
|
save_json_data('expenses', expenses) |
|
|
upload_db_to_hf('expenses') |
|
|
flash("Расход успешно добавлен.", "success") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/expense/delete/<expense_id>', methods=['POST']) |
|
|
@admin_required |
|
|
def delete_expense(expense_id): |
|
|
expenses = load_json_data('expenses') |
|
|
initial_len = len(expenses) |
|
|
expenses = [e for e in expenses if e.get('id') != expense_id] |
|
|
if len(expenses) < initial_len: |
|
|
save_json_data('expenses', expenses) |
|
|
upload_db_to_hf('expenses') |
|
|
flash("Расход удален.", "success") |
|
|
else: |
|
|
flash("Расход не найден.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/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('/cashier_dashboard/<user_id>') |
|
|
def cashier_dashboard(user_id): |
|
|
users = load_json_data('users') |
|
|
user = find_item_by_field(users, 'id', user_id) |
|
|
if not user: |
|
|
abort(404, "Кассир не найден") |
|
|
|
|
|
transactions = load_json_data('transactions') |
|
|
user_transactions = [t for t in transactions if t.get('user_id') == user_id] |
|
|
user_transactions.sort(key=lambda x: x['timestamp'], reverse=True) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {user['name']}").replace('__CONTENT__', CASHIER_DASHBOARD_CONTENT).replace('__SCRIPTS__', '') |
|
|
return render_template_string(html, user=user, transactions=user_transactions) |
|
|
|
|
|
@app.route('/return_transaction/<transaction_id>', methods=['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') |
|
|
|
|
|
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']))}) |
|
|
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: |
|
|
inventory_updates[item['variant_id']] = {'product_id': item['product_id'], 'new_stock': variant.get('stock', 0) + 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'], |
|
|
'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 variant_id, update_info in inventory_updates.items(): |
|
|
for p in inventory: |
|
|
if p.get('id') == update_info['product_id']: |
|
|
for v in p.get('variants', []): |
|
|
if v.get('id') == variant_id: |
|
|
v['stock'] = update_info['new_stock'] |
|
|
p['timestamp_updated'] = now_iso |
|
|
break |
|
|
break |
|
|
|
|
|
if original_transaction['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 |
|
|
|
|
|
save_json_data('transactions', transactions) |
|
|
save_json_data('inventory', inventory) |
|
|
save_json_data('kassas', kassas) |
|
|
upload_db_to_hf('transactions') |
|
|
upload_db_to_hf('inventory') |
|
|
upload_db_to_hf('kassas') |
|
|
|
|
|
flash("Возврат успешно оформлен.", "success") |
|
|
return redirect(url_for('cashier_dashboard', user_id=cashier_id)) |
|
|
|
|
|
@app.route('/backup', methods=['POST']) |
|
|
@admin_required |
|
|
def backup_hf(): |
|
|
try: |
|
|
for key in DATA_FILES.keys(): |
|
|
upload_db_to_hf(key) |
|
|
flash(f"Резервное копирование {len(DATA_FILES)} файлов инициировано.", "success") |
|
|
except Exception as e: |
|
|
flash(f"Ошибка при резервном копировании: {e}", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/download', methods=['GET']) |
|
|
@admin_required |
|
|
def download_hf(): |
|
|
errors = [] |
|
|
success_count = 0 |
|
|
for key in DATA_FILES.keys(): |
|
|
filepath, _ = DATA_FILES[key] |
|
|
filename = os.path.basename(filepath) |
|
|
try: |
|
|
hf_hub_download( |
|
|
repo_id=REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN_READ, |
|
|
local_dir=DATA_DIR, local_dir_use_symlinks=False, force_download=True, |
|
|
) |
|
|
success_count += 1 |
|
|
except Exception as e: |
|
|
errors.append(f"Ошибка загрузки {filename}: {e}") |
|
|
if success_count > 0: |
|
|
flash(f"Успешно загружено {success_count} файлов. Данные перезаписаны.", "success") |
|
|
if errors: |
|
|
flash("Произошли ошибки: " + "; ".join(errors), "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin_login', methods=['GET', 'POST']) |
|
|
def admin_login(): |
|
|
if request.method == 'POST': |
|
|
password = request.form.get('password') |
|
|
if password == ADMIN_PASS: |
|
|
session['admin_logged_in'] = True |
|
|
session.permanent = True |
|
|
next_url = request.args.get('next') |
|
|
flash("Вы успешно вошли в систему.", "success") |
|
|
return redirect(next_url or url_for('admin_panel')) |
|
|
else: |
|
|
flash("Неверный пароль.", "danger") |
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Вход для администратора").replace('__CONTENT__', ADMIN_LOGIN_CONTENT).replace('__SCRIPTS__', '') |
|
|
return render_template_string(html) |
|
|
|
|
|
@app.route('/admin_logout') |
|
|
def admin_logout(): |
|
|
session.pop('admin_logged_in', None) |
|
|
flash("Вы вышли из системы.", "info") |
|
|
return redirect(url_for('sales_screen')) |
|
|
|
|
|
BASE_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru" data-bs-theme="light"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>__TITLE__ - POS</title> |
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<style> |
|
|
:root { --sidebar-width: 250px; } |
|
|
body { background-color: #f8f9fa; } |
|
|
.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background-color: #343a40; padding-top: 1rem; } |
|
|
.sidebar .nav-link { color: rgba(255,255,255,.75); } |
|
|
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: #fff; } |
|
|
.main-content { margin-left: var(--sidebar-width); padding: 1.5rem; } |
|
|
@media (max-width: 992px) { |
|
|
.sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); transition: transform 0.3s ease-in-out; z-index: 1040; } |
|
|
.sidebar.active { transform: translateX(0); } |
|
|
.main-content { margin-left: 0; } |
|
|
} |
|
|
[data-bs-theme="dark"] body { background-color: #212529; color: #dee2e6; } |
|
|
[data-bs-theme="dark"] .card, [data-bs-theme="dark"] .modal-content, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table, [data-bs-theme="dark"] .accordion-item { background-color: #343a40; } |
|
|
[data-bs-theme="dark"] .accordion-button { background-color: #3e444a; color: #fff; } |
|
|
[data-bs-theme="dark"] .accordion-button:not(.collapsed) { background-color: #495057;} |
|
|
[data-bs-theme="dark"] .accordion-button::after { filter: invert(1) grayscale(100) brightness(200%); } |
|
|
[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); } |
|
|
.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); } |
|
|
</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> |
|
|
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'admin_panel' %}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"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h5 class="modal-title" id="variantSelectModalTitle">Выберите вариант</h5> |
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|
|
</div> |
|
|
<div class="modal-body"> |
|
|
<div id="variant-list" class="list-group"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> |
|
|
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script> |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
document.getElementById('sidebar-toggle')?.addEventListener('click', () => document.querySelector('.sidebar').classList.toggle('active')); |
|
|
const themeToggle = document.getElementById('theme-toggle'); |
|
|
const getStoredTheme = () => localStorage.getItem('theme'); |
|
|
const setStoredTheme = theme => localStorage.setItem('theme', theme); |
|
|
const getPreferredTheme = () => getStoredTheme() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); |
|
|
const setTheme = theme => { |
|
|
document.documentElement.setAttribute('data-bs-theme', theme); |
|
|
themeToggle.innerHTML = theme === 'dark' ? '<i class="fas fa-moon fa-lg"></i>' : '<i class="fas fa-sun fa-lg"></i>'; |
|
|
}; |
|
|
setTheme(getPreferredTheme()); |
|
|
themeToggle.addEventListener('click', () => { |
|
|
const newTheme = getPreferredTheme() === 'light' ? 'dark' : 'light'; |
|
|
setStoredTheme(newTheme); |
|
|
setTheme(newTheme); |
|
|
}); |
|
|
}); |
|
|
</script> |
|
|
__SCRIPTS__ |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
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> |
|
|
<button id="scan-btn" class="btn btn-primary btn-sm"><i class="fas fa-barcode me-2"></i>Сканировать</button> |
|
|
</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"><h5 class="mb-0">Чек</h5></div> |
|
|
<div class="card-body"> |
|
|
<div class="row g-2 mb-3"> |
|
|
<div class="col-6"><select id="user-select" class="form-select"><option value="">-- Кассир --</option>{% for u in users %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div> |
|
|
<div class="col-6"><select id="kassa-select" class="form-select"><option value="">-- Касса --</option>{% for k in kassas %}<option value="{{ k.id }}">{{ k.name }}</option>{% endfor %}</select></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-danger" id="clear-cart-btn">Очистить</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
SALES_SCREEN_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
const cart = {}; |
|
|
const productGrid = document.getElementById('product-accordion'); |
|
|
const cartItemsEl = document.getElementById('cart-items'); |
|
|
const cartTotalEl = document.getElementById('cart-total'); |
|
|
let audioCtx; |
|
|
let isScannerPaused = false; |
|
|
const receiptModal = new bootstrap.Modal(document.getElementById('receiptModal')); |
|
|
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal')); |
|
|
|
|
|
function playBeep() { |
|
|
if (!audioCtx) { |
|
|
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } |
|
|
catch (e) { console.error("Web Audio API is not supported"); return; } |
|
|
} |
|
|
const oscillator = audioCtx.createOscillator(); |
|
|
const gainNode = audioCtx.createGain(); |
|
|
oscillator.connect(gainNode); |
|
|
gainNode.connect(audioCtx.destination); |
|
|
oscillator.type = 'sine'; |
|
|
oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); |
|
|
gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime); |
|
|
gainNode.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.15); |
|
|
oscillator.start(audioCtx.currentTime); |
|
|
oscillator.stop(audioCtx.currentTime + 0.15); |
|
|
} |
|
|
|
|
|
function parseLocaleNumber(stringNumber) { |
|
|
return parseFloat(String(stringNumber).replace(/\\s/g, '').replace(',', '.')) || 0; |
|
|
} |
|
|
|
|
|
const updateCartView = () => { |
|
|
cartItemsEl.innerHTML = ''; |
|
|
let 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; |
|
|
cartItemsEl.innerHTML += ` |
|
|
<div class="list-group-item"> |
|
|
<div class="d-flex justify-content-between align-items-start"> |
|
|
<div> |
|
|
<h6 class="mb-0 small">${item.productName} (${item.variantName})</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 = total.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' с'; |
|
|
}; |
|
|
|
|
|
const addToCart = (product, variant) => { |
|
|
if (cart[variant.id]) { |
|
|
cart[variant.id].quantity++; |
|
|
} else { |
|
|
cart[variant.id] = { |
|
|
productId: product.id, |
|
|
productName: product.name, |
|
|
variantName: variant.option_value, |
|
|
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) { |
|
|
addToCart(product, product.variants[0]); |
|
|
} else { |
|
|
document.getElementById('variantSelectModalTitle').textContent = `Выберите вариант для "${product.name}"`; |
|
|
const variantList = document.getElementById('variant-list'); |
|
|
variantList.innerHTML = ''; |
|
|
product.variants.forEach(variant => { |
|
|
const btn = document.createElement('button'); |
|
|
btn.type = 'button'; |
|
|
btn.className = 'list-group-item list-group-item-action'; |
|
|
btn.innerHTML = `${variant.option_value} - <strong>${parseFloat(variant.price).toLocaleString('ru-RU', {minimumFractionDigits: 2})} с</strong> <span class="badge bg-secondary float-end">Остаток: ${variant.stock}</span>`; |
|
|
btn.addEventListener('click', () => { |
|
|
addToCart(product, variant); |
|
|
variantSelectModal.hide(); |
|
|
}); |
|
|
variantList.appendChild(btn); |
|
|
}); |
|
|
variantSelectModal.show(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const fetchAndHandleProduct = (barcode) => { |
|
|
fetch(`/api/product_by_barcode/${barcode}`) |
|
|
.then(res => res.json()) |
|
|
.then(data => { |
|
|
if (data.success) handleProductSelection(data.product); |
|
|
else alert(data.message); |
|
|
}); |
|
|
} |
|
|
|
|
|
productGrid.addEventListener('click', e => { |
|
|
const card = e.target.closest('.product-card'); |
|
|
if (card) { |
|
|
fetchAndHandleProduct(card.dataset.barcode); |
|
|
} |
|
|
}); |
|
|
|
|
|
const updateCartItemQuantity = (id, newQuantity) => { |
|
|
if (cart[id]) { |
|
|
const qty = parseInt(newQuantity, 10); |
|
|
if (!isNaN(qty) && qty > 0) { |
|
|
cart[id].quantity = qty; |
|
|
} else { |
|
|
delete cart[id]; |
|
|
} |
|
|
updateCartView(); |
|
|
} |
|
|
}; |
|
|
|
|
|
cartItemsEl.addEventListener('click', e => { |
|
|
if (e.target.classList.contains('cart-qty-btn')) { |
|
|
const id = e.target.dataset.id; |
|
|
const op = parseInt(e.target.dataset.op); |
|
|
if (cart[id]) { |
|
|
const newQuantity = cart[id].quantity + op; |
|
|
updateCartItemQuantity(id, newQuantity); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
cartItemsEl.addEventListener('change', e => { |
|
|
if (e.target.classList.contains('cart-qty-input')) { |
|
|
const id = e.target.dataset.id; |
|
|
updateCartItemQuantity(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(); |
|
|
const show = productName.includes(term) || barcode.includes(term); |
|
|
card.style.display = show ? '' : '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) => { |
|
|
const userId = document.getElementById('user-select').value; |
|
|
const kassaId = document.getElementById('kassa-select').value; |
|
|
if (!userId || !kassaId) { |
|
|
alert('Выберите кассира и кассу!'); |
|
|
return; |
|
|
} |
|
|
if (Object.keys(cart).length === 0) { |
|
|
alert('Корзина пуста!'); |
|
|
return; |
|
|
} |
|
|
fetch('/api/complete_sale', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({ cart, userId, kassaId, 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(); |
|
|
} 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('send-whatsapp-btn').addEventListener('click', () => { |
|
|
const phone = document.getElementById('whatsapp-phone').value.replace(/\\D/g, ''); |
|
|
const receiptUrl = document.getElementById('receipt-url').value; |
|
|
if (phone && receiptUrl) { |
|
|
const fullPhone = '996' + phone; |
|
|
const message = encodeURIComponent(`Ваш чек: ${receiptUrl}`); |
|
|
window.open(`https://wa.me/${fullPhone}?text=${message}`, '_blank'); |
|
|
} else { |
|
|
alert('Введите номер телефона.'); |
|
|
} |
|
|
}); |
|
|
|
|
|
const html5QrCode = new Html5Qrcode("reader"); |
|
|
const scannerStatusEl = document.getElementById('scanner-status'); |
|
|
|
|
|
const onScanSuccess = (decodedText, decodedResult) => { |
|
|
if (isScannerPaused) return; |
|
|
isScannerPaused = true; |
|
|
scannerStatusEl.textContent = 'Пауза...'; |
|
|
scannerStatusEl.style.display = 'block'; |
|
|
if (html5QrCode.getState() === 2) html5QrCode.pause(); |
|
|
|
|
|
fetchAndHandleProduct(decodedText); |
|
|
|
|
|
setTimeout(() => { |
|
|
isScannerPaused = false; |
|
|
scannerStatusEl.style.display = 'none'; |
|
|
if (html5QrCode.getState() === 2) html5QrCode.resume(); |
|
|
}, 1500); |
|
|
}; |
|
|
|
|
|
const startScanner = () => { |
|
|
document.getElementById('scanner-container').style.display = 'block'; |
|
|
const config = { fps: 10, qrbox: { width: 250, height: 250 } }; |
|
|
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); |
|
|
|
|
|
updateCartView(); |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
INVENTORY_CONTENT = """ |
|
|
<div class="d-flex justify-content-between mb-3"> |
|
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProductModal"><i class="fas fa-plus me-2"></i>Добавить товар</button> |
|
|
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#stockInModal"><i class="fas fa-truck-loading me-2"></i>Оприходовать</button> |
|
|
</div> |
|
|
<input type="text" id="inventory-search" class="form-control mb-3" placeholder="Поиск по названию, варианту или штрих-коду..."> |
|
|
|
|
|
<div class="accordion" id="inventoryAccordion"> |
|
|
{% for p in inventory %} |
|
|
<div class="accordion-item"> |
|
|
<h2 class="accordion-header" id="heading-{{ p.id }}"> |
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ p.id }}"> |
|
|
<strong>{{ p.name }}</strong> <small class="text-muted"> ({{ p.barcode }})</small> |
|
|
</button> |
|
|
</h2> |
|
|
<div id="collapse-{{ p.id }}" class="accordion-collapse collapse" data-bs-parent="#inventoryAccordion"> |
|
|
<div class="accordion-body"> |
|
|
<div class="d-flex justify-content-end mb-2"> |
|
|
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editProductModal-{{ p.id }}"><i class="fas fa-edit me-1"></i>Редактировать товар</button> |
|
|
<form action="{{ url_for('delete_product', product_id=p.id) }}" method="POST" class="d-inline ms-2" onsubmit="return confirm('Удалить товар?');"> |
|
|
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button> |
|
|
</form> |
|
|
</div> |
|
|
<table class="table table-sm table-bordered"> |
|
|
<thead><tr><th>Вариант</th><th>Цена</th><th>Себест.</th><th>Остаток</th></tr></thead> |
|
|
<tbody> |
|
|
{% for v in p.variants %} |
|
|
<tr> |
|
|
<td>{{ v.option_value }}</td> |
|
|
<td>{{ format_currency_py(v.price) }} с</td> |
|
|
<td>{{ format_currency_py(v.cost_price) }} с</td> |
|
|
<td>{{ v.stock }}</td> |
|
|
</tr> |
|
|
{% else %} |
|
|
<tr><td colspan="4" class="text-center text-muted">Нет вариантов</td></tr> |
|
|
{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
|
|
|
<div class="modal fade" id="addProductModal" tabindex="-1"> |
|
|
<div class="modal-dialog modal-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> |
|
|
<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-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> |
|
|
<form action="{{ url_for('edit_product', product_id=p.id) }}" method="POST"> |
|
|
<div class="modal-body"> |
|
|
<div class="row"> |
|
|
<div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" class="form-control" value="{{ p.name }}" required></div> |
|
|
<div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label><input type="text" name="barcode" class="form-control" value="{{ p.barcode }}" required></div> |
|
|
</div> |
|
|
<hr> |
|
|
<h6>Варианты товара</h6> |
|
|
<div id="variants-container-edit-{{ p.id }}"> |
|
|
{% for v in p.variants %} |
|
|
<div class="row g-2 mb-2 align-items-center variant-row"> |
|
|
<input type="hidden" name="variant_id[]" value="{{ v.id }}"> |
|
|
<div class="col-3"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" value="{{ v.option_value }}" required></div> |
|
|
<div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" value="{{ v.price|string|replace('.', ',') }}" inputmode="decimal"></div> |
|
|
<div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" value="{{ v.cost_price|string|replace('.', ',') }}" inputmode="decimal"></div> |
|
|
<div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="{{ v.stock }}"></div> |
|
|
<div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
<button type="button" class="btn btn-sm btn-outline-success mt-2 add-variant-btn-edit" data-target-container="variants-container-edit-{{ p.id }}">Добавить вариант</button> |
|
|
</div> |
|
|
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
|
|
|
<div class="modal fade" id="stockInModal" tabindex="-1"> |
|
|
<div class="modal-dialog"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"><h5 class="modal-title">Оприходование товара</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> |
|
|
<form action="{{ url_for('stock_in') }}" method="POST"> |
|
|
<div class="modal-body"> |
|
|
<div class="mb-3"> |
|
|
<label for="stockin-product" class="form-label">Товар</label> |
|
|
<select id="stockin-product" name="product_id" class="form-select" required> |
|
|
<option value="">-- Выберите товар --</option> |
|
|
{% for p in inventory %} |
|
|
<option value="{{ p.id }}">{{ p.name }}</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
</div> |
|
|
<div class="mb-3"> |
|
|
<label for="stockin-variant" class="form-label">Вариант</label> |
|
|
<select id="stockin-variant" name="variant_id" class="form-select" required disabled> |
|
|
<option value="">-- Сначала выберите товар --</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<div class="col-md-6 mb-3"> |
|
|
<label for="stockin-quantity" class="form-label">Количество</label> |
|
|
<input type="number" id="stockin-quantity" name="quantity" class="form-control" required min="1"> |
|
|
</div> |
|
|
<div class="col-md-6 mb-3"> |
|
|
<label for="stockin-cost" class="form-label">Себестоимость (за ед.)</label> |
|
|
<input type="text" id="stockin-cost" name="cost_price" class="form-control" inputmode="decimal" placeholder="Необязательно"> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mb-3"> |
|
|
<label for="stockin-delivery" class="form-label">Стоимость доставки (общая)</label> |
|
|
<input type="text" id="stockin-delivery" name="delivery_cost" class="form-control" inputmode="decimal" placeholder="0"> |
|
|
<div class="form-text">Эта сумма будет добавлена в расходы как "Дорога" и учтена в себестоимости.</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal-footer"><button type="submit" class="btn btn-primary">Оприходовать</button></div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
INVENTORY_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
let currentScanner = null; |
|
|
let currentScannerContainer = null; |
|
|
document.querySelectorAll('.scan-modal-btn').forEach(btn => { |
|
|
btn.addEventListener('click', e => { |
|
|
const form = e.target.closest('form'); |
|
|
const scannerContainer = form.querySelector('[id^="modal-scanner-"]'); |
|
|
const barcodeInput = form.querySelector('.barcode-input'); |
|
|
|
|
|
if (currentScanner) { |
|
|
try { currentScanner.stop(); } catch(e) {} |
|
|
currentScanner = null; |
|
|
if(currentScannerContainer) currentScannerContainer.style.display = 'none'; |
|
|
return; |
|
|
} |
|
|
scannerContainer.style.display = 'block'; |
|
|
currentScannerContainer = scannerContainer; |
|
|
const scannerId = scannerContainer.id + '-reader'; |
|
|
if(!document.getElementById(scannerId)) scannerContainer.innerHTML = `<div id="${scannerId}" style="width: 100%;"></div>`; |
|
|
|
|
|
const html5QrCode = new Html5Qrcode(scannerId); |
|
|
currentScanner = html5QrCode; |
|
|
const onScanSuccess = (decodedText, decodedResult) => { |
|
|
barcodeInput.value = decodedText; |
|
|
try { html5QrCode.stop(); } catch(e) {} |
|
|
currentScanner = null; |
|
|
scannerContainer.style.display = 'none'; |
|
|
}; |
|
|
html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: { width: 250, height: 250 } }, onScanSuccess); |
|
|
}); |
|
|
}); |
|
|
|
|
|
document.querySelectorAll('.modal').forEach(modal => { |
|
|
modal.addEventListener('hidden.bs.modal', () => { |
|
|
if (currentScanner) { |
|
|
try { currentScanner.stop(); } catch(e) {} |
|
|
currentScanner = null; |
|
|
} |
|
|
if(currentScannerContainer) { |
|
|
currentScannerContainer.style.display = 'none'; |
|
|
currentScannerContainer.innerHTML = ''; |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
const createVariantRow = () => { |
|
|
const div = document.createElement('div'); |
|
|
div.className = 'row g-2 mb-2 align-items-center variant-row'; |
|
|
div.innerHTML = ` |
|
|
<input type="hidden" name="variant_id[]" value=""> |
|
|
<div class="col-3"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" required></div> |
|
|
<div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" inputmode="decimal"></div> |
|
|
<div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" inputmode="decimal"></div> |
|
|
<div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="0"></div> |
|
|
<div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div> |
|
|
`; |
|
|
div.querySelector('.remove-variant-btn').addEventListener('click', () => div.remove()); |
|
|
return div; |
|
|
}; |
|
|
|
|
|
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) => { |
|
|
const containerId = e.target.dataset.targetContainer; |
|
|
document.getElementById(containerId).appendChild(createVariantRow()); |
|
|
}); |
|
|
}); |
|
|
|
|
|
document.querySelectorAll('.remove-variant-btn').forEach(btn => { |
|
|
btn.addEventListener('click', e => e.target.closest('.variant-row').remove()); |
|
|
}); |
|
|
|
|
|
const addProductModal = document.getElementById('addProductModal'); |
|
|
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 => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = v.id; |
|
|
option.textContent = v.option_value; |
|
|
variantSelect.appendChild(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 variantNameElements = item.querySelectorAll('.accordion-body table tbody tr td:first-child'); |
|
|
const variantMatch = Array.from(variantNameElements).some(td => td.textContent.toLowerCase().includes(term)); |
|
|
|
|
|
const show = productName.includes(term) || barcode.includes(term) || variantMatch; |
|
|
item.style.display = show ? '' : '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"> |
|
|
<button type="submit" class="btn btn-primary">Показать</button> |
|
|
</div> |
|
|
</form> |
|
|
<hr> |
|
|
<h4 class="mb-0">Общая выручка за {{ selected_date }}: <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></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.type == 'sale' and t.receipt_html else '#' }}" target="_blank"><small class="text-muted">{{ t.id[:8] }}</small></a></td> |
|
|
<td>{{ t.timestamp[11:16] }}</td> |
|
|
<td><span class="badge bg-{{'primary' if t.type == 'sale' else 'warning'}}">{{'Продажа' if t.type == 'sale' else 'Возврат'}}</span></td> |
|
|
<td>{{ t.user_name }}</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 }} ({{ 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> |
|
|
</tr> |
|
|
{% else %} |
|
|
<tr><td colspan="8" class="text-center">Нет транзакций за выбранную дату.</td></tr> |
|
|
{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
TRANSACTIONS_SCRIPTS = "" |
|
|
|
|
|
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-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"><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> |
|
|
</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-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-12 mb-4"> |
|
|
<div class="card"> |
|
|
<div class="card-header"><h5 class="mb-0">Учет расходов</h5></div> |
|
|
<div class="card-body"> |
|
|
<form action="{{ url_for('manage_expense') }}" method="POST" class="mb-4"> |
|
|
<div class="row g-2 align-items-end"> |
|
|
<div class="col-md-3"><label class="form-label">Сумма</label><input type="text" name="amount" class="form-control" required inputmode="decimal"></div> |
|
|
<div class="col-md-7"><label class="form-label">Описание</label><input type="text" name="description" class="form-control" required placeholder="Напр: Аренда за май"></div> |
|
|
<div class="col-md-2"><button type="submit" class="btn btn-warning w-100">Добавить расход</button></div> |
|
|
</div> |
|
|
</form> |
|
|
<h6>Все расходы:</h6> |
|
|
<div class="table-responsive" 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[:16]|replace('T', ' ') }}</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-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 = "" |
|
|
|
|
|
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> |
|
|
""" |
|
|
|
|
|
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) |
|
|
|