Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import logging
|
|
@@ -12,9 +12,11 @@ import uuid
|
|
| 12 |
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
| 13 |
import functools
|
| 14 |
from collections import defaultdict
|
|
|
|
| 15 |
|
| 16 |
app = Flask(__name__)
|
| 17 |
app.secret_key = os.urandom(24)
|
|
|
|
| 18 |
|
| 19 |
DATA_DIR = 'pos_data'
|
| 20 |
os.makedirs(DATA_DIR, exist_ok=True)
|
|
@@ -78,7 +80,7 @@ def load_json_data(file_key):
|
|
| 78 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 79 |
return json.load(f)
|
| 80 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 81 |
-
return []
|
| 82 |
|
| 83 |
def save_json_data(file_key, data):
|
| 84 |
filepath, lock = DATA_FILES[file_key]
|
|
@@ -130,7 +132,7 @@ def periodic_backup():
|
|
| 130 |
|
| 131 |
def find_item_by_field(data, field, value):
|
| 132 |
for item in data:
|
| 133 |
-
if isinstance(item, dict) and item.get(field) == value:
|
| 134 |
return item
|
| 135 |
return None
|
| 136 |
|
|
@@ -143,7 +145,7 @@ def format_currency_py(value):
|
|
| 143 |
|
| 144 |
@app.context_processor
|
| 145 |
def inject_utils():
|
| 146 |
-
return {'format_currency_py': format_currency_py, 'get_current_time': get_current_time}
|
| 147 |
|
| 148 |
@app.route('/')
|
| 149 |
def sales_screen():
|
|
@@ -329,7 +331,9 @@ def complete_sale():
|
|
| 329 |
'kassa_name': kassa.get('name', 'N/A'),
|
| 330 |
'items': sale_items,
|
| 331 |
'total_amount': str(total_amount),
|
| 332 |
-
'payment_method': payment_method
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
|
| 335 |
transactions = load_json_data('transactions')
|
|
@@ -375,49 +379,86 @@ def complete_sale():
|
|
| 375 |
def transaction_history():
|
| 376 |
transactions = load_json_data('transactions')
|
| 377 |
transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 378 |
-
html = BASE_TEMPLATE.replace('__TITLE__', "История
|
| 379 |
return render_template_string(html, transactions=transactions)
|
| 380 |
|
| 381 |
@app.route('/reports')
|
| 382 |
def reports():
|
| 383 |
today = get_current_time().date()
|
| 384 |
start_date_str = request.args.get('start_date', (today.replace(day=1)).strftime('%Y-%m-%d'))
|
| 385 |
-
end_date_str = request.args.get('end_date',
|
| 386 |
|
| 387 |
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').replace(tzinfo=BISHKEK_TZ)
|
| 388 |
-
|
|
|
|
|
|
|
| 389 |
|
| 390 |
transactions = load_json_data('transactions')
|
| 391 |
expenses = load_json_data('expenses')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
]
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
net_profit = gross_profit - total_expenses
|
| 410 |
|
| 411 |
sales_by_cashier = defaultdict(lambda: {'count': 0, 'total': Decimal(0)})
|
| 412 |
for t in filtered_transactions:
|
|
|
|
| 413 |
cashier_name = t.get('user_name', 'Неизвестный')
|
| 414 |
sales_by_cashier[cashier_name]['count'] += 1
|
| 415 |
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
|
| 416 |
|
| 417 |
stats = {
|
| 418 |
'total_revenue': total_revenue,
|
|
|
|
|
|
|
| 419 |
'total_cogs': total_cogs,
|
| 420 |
'gross_profit': gross_profit,
|
|
|
|
|
|
|
|
|
|
| 421 |
'total_expenses': total_expenses,
|
| 422 |
'net_profit': net_profit,
|
| 423 |
'sales_by_cashier': sorted(sales_by_cashier.items(), key=lambda item: item[1]['total'], reverse=True)
|
|
@@ -445,10 +486,22 @@ def manage_user():
|
|
| 445 |
if action == 'add':
|
| 446 |
name = request.form.get('name', '').strip()
|
| 447 |
pin = request.form.get('pin', '').strip()
|
|
|
|
|
|
|
|
|
|
| 448 |
if name and pin and pin.isdigit():
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
else:
|
| 453 |
flash("Имя и цифровой ПИН-код обязательны.", "danger")
|
| 454 |
|
|
@@ -465,6 +518,44 @@ def manage_user():
|
|
| 465 |
upload_db_to_hf('users')
|
| 466 |
return redirect(url_for('admin_panel'))
|
| 467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
@app.route('/admin/kassa', methods=['POST'])
|
| 469 |
def manage_kassa():
|
| 470 |
action = request.form.get('action')
|
|
@@ -604,6 +695,144 @@ def download_hf():
|
|
| 604 |
flash("Произошли ошибки: " + "; ".join(errors), "danger")
|
| 605 |
return redirect(url_for('admin_panel'))
|
| 606 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
BASE_TEMPLATE = """
|
| 608 |
<!DOCTYPE html>
|
| 609 |
<html lang="ru" data-bs-theme="light">
|
|
@@ -627,6 +856,7 @@ BASE_TEMPLATE = """
|
|
| 627 |
}
|
| 628 |
[data-bs-theme="dark"] body { background-color: #212529; color: #dee2e6; }
|
| 629 |
[data-bs-theme="dark"] .card, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table { background-color: #343a40; }
|
|
|
|
| 630 |
[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); }
|
| 631 |
</style>
|
| 632 |
</head>
|
|
@@ -638,7 +868,8 @@ BASE_TEMPLATE = """
|
|
| 638 |
<ul class="nav flex-column">
|
| 639 |
<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>
|
| 640 |
<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>
|
| 641 |
-
<li class="nav-item"><a class="nav-link {% if request.endpoint
|
|
|
|
| 642 |
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'reports' %}active{% endif %}" href="{{ url_for('reports') }}"><i class="fas fa-fw fa-chart-line me-2"></i>Отчеты</a></li>
|
| 643 |
<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>
|
| 644 |
</ul>
|
|
@@ -743,6 +974,29 @@ SALES_SCREEN_CONTENT = """
|
|
| 743 |
</div>
|
| 744 |
</div>
|
| 745 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
"""
|
| 747 |
|
| 748 |
SALES_SCREEN_SCRIPTS = """
|
|
@@ -754,6 +1008,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 754 |
const cartTotalEl = document.getElementById('cart-total');
|
| 755 |
let audioCtx;
|
| 756 |
let isScannerPaused = false;
|
|
|
|
| 757 |
|
| 758 |
function playBeep() {
|
| 759 |
if (!audioCtx) {
|
|
@@ -870,7 +1125,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 870 |
.then(res => res.json())
|
| 871 |
.then(data => {
|
| 872 |
if (data.success) {
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
| 874 |
for(const id in cart) delete cart[id];
|
| 875 |
updateCartView();
|
| 876 |
} else {
|
|
@@ -883,6 +1141,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 883 |
document.getElementById('pay-cash-btn').addEventListener('click', () => completeSale('cash'));
|
| 884 |
document.getElementById('pay-card-btn').addEventListener('click', () => completeSale('card'));
|
| 885 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
const html5QrCode = new Html5Qrcode("reader");
|
| 887 |
const scannerStatusEl = document.getElementById('scanner-status');
|
| 888 |
|
|
@@ -1011,30 +1283,41 @@ INVENTORY_SCRIPTS = """
|
|
| 1011 |
<script>
|
| 1012 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1013 |
let currentScanner = null;
|
|
|
|
| 1014 |
document.querySelectorAll('.scan-modal-btn').forEach(btn => {
|
| 1015 |
btn.addEventListener('click', e => {
|
| 1016 |
const form = e.target.closest('form');
|
| 1017 |
const scannerContainer = form.querySelector('[id^="modal-scanner-"]');
|
| 1018 |
const barcodeInput = form.querySelector('.barcode-input');
|
| 1019 |
-
|
|
|
|
| 1020 |
currentScanner.stop();
|
| 1021 |
currentScanner = null;
|
| 1022 |
-
|
|
|
|
| 1023 |
return;
|
|
|
|
|
|
|
|
|
|
| 1024 |
}
|
|
|
|
| 1025 |
scannerContainer.style.display = 'block';
|
| 1026 |
const scannerId = scannerContainer.id + '-reader';
|
| 1027 |
-
if(!document.getElementById(scannerId)) scannerContainer.innerHTML = `<div id="${scannerId}"></div>`;
|
| 1028 |
|
| 1029 |
const html5QrCode = new Html5Qrcode(scannerId);
|
| 1030 |
currentScanner = html5QrCode;
|
|
|
|
| 1031 |
const onScanSuccess = (decodedText, decodedResult) => {
|
| 1032 |
barcodeInput.value = decodedText;
|
| 1033 |
-
html5QrCode.stop()
|
| 1034 |
-
|
| 1035 |
-
|
|
|
|
|
|
|
| 1036 |
};
|
| 1037 |
-
html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: 250 }, onScanSuccess)
|
|
|
|
| 1038 |
});
|
| 1039 |
});
|
| 1040 |
|
|
@@ -1043,8 +1326,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 1043 |
if (currentScanner) {
|
| 1044 |
currentScanner.stop();
|
| 1045 |
currentScanner = null;
|
|
|
|
|
|
|
| 1046 |
}
|
| 1047 |
-
modal.querySelectorAll('[id^="modal-scanner-"]').forEach(c => c.style.display = 'none');
|
| 1048 |
});
|
| 1049 |
});
|
| 1050 |
});
|
|
@@ -1056,15 +1340,27 @@ TRANSACTIONS_CONTENT = """
|
|
| 1056 |
<div class="card-body">
|
| 1057 |
<div class="table-responsive">
|
| 1058 |
<table class="table table-sm table-hover">
|
| 1059 |
-
<thead><tr><th>ID</th><th>Дата</th><th>Кассир</th><th>Касса</th><th>Сумма</th><th>Тип опл.</th><th>Позиции</th></tr></thead>
|
| 1060 |
<tbody>
|
| 1061 |
{% for t in transactions %}
|
| 1062 |
-
<tr>
|
| 1063 |
<td><small class="text-muted">{{ t.id[:8] }}</small></td>
|
| 1064 |
<td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1065 |
<td>{{ t.user_name }}</td><td>{{ t.kassa_name }}</td>
|
| 1066 |
<td class="fw-bold">{{ format_currency_py(t.total_amount) }} с</td>
|
| 1067 |
-
<td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1068 |
<td>
|
| 1069 |
<ul class="list-unstyled mb-0 small">
|
| 1070 |
{% for item in t['items'] %}<li>{{ item.name }} ({{ item.quantity }}x{{ format_currency_py(item.price_at_sale) }})</li>{% endfor %}
|
|
@@ -1100,19 +1396,36 @@ REPORTS_CONTENT = """
|
|
| 1100 |
<div class="card-header"><h5 class="mb-0">Сводный отчет за период</h5></div>
|
| 1101 |
<div class="card-body">
|
| 1102 |
<ul class="list-group list-group-flush">
|
| 1103 |
-
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-
|
| 1104 |
-
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-
|
|
|
|
|
|
|
| 1105 |
<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>
|
| 1106 |
-
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-receipt me-2 text-danger"></i>
|
|
|
|
| 1107 |
<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>
|
| 1108 |
</ul>
|
| 1109 |
</div>
|
| 1110 |
</div>
|
| 1111 |
<div class="card mb-4">
|
| 1112 |
-
<div class="card-header"><h5 class="mb-0">Расходы
|
| 1113 |
<div class="card-body p-0">
|
| 1114 |
-
<div class="table-responsive">
|
| 1115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th></tr></thead>
|
| 1117 |
<tbody>
|
| 1118 |
{% for expense in expenses %}
|
|
@@ -1125,8 +1438,7 @@ REPORTS_CONTENT = """
|
|
| 1125 |
<tr><td colspan="3" class="text-center text-muted">Нет расходов за выбранный период.</td></tr>
|
| 1126 |
{% endfor %}
|
| 1127 |
</tbody>
|
| 1128 |
-
</table>
|
| 1129 |
-
</div>
|
| 1130 |
</div>
|
| 1131 |
</div>
|
| 1132 |
</div>
|
|
@@ -1134,8 +1446,7 @@ REPORTS_CONTENT = """
|
|
| 1134 |
<div class="card mb-4">
|
| 1135 |
<div class="card-header"><h5 class="mb-0">Продажи по кассирам</h5></div>
|
| 1136 |
<div class="card-body p-0">
|
| 1137 |
-
<div class="table-responsive">
|
| 1138 |
-
<table class="table table-hover mb-0">
|
| 1139 |
<thead><tr><th>Кассир</th><th>Чеков</th><th class="text-end">Сумма</th></tr></thead>
|
| 1140 |
<tbody>
|
| 1141 |
{% for name, data in stats.sales_by_cashier %}
|
|
@@ -1148,8 +1459,7 @@ REPORTS_CONTENT = """
|
|
| 1148 |
<tr><td colspan="3" class="text-center text-muted">Нет продаж за выбранный период.</td></tr>
|
| 1149 |
{% endfor %}
|
| 1150 |
</tbody>
|
| 1151 |
-
</table>
|
| 1152 |
-
</div>
|
| 1153 |
</div>
|
| 1154 |
</div>
|
| 1155 |
</div>
|
|
@@ -1160,26 +1470,34 @@ REPORTS_SCRIPTS = ""
|
|
| 1160 |
|
| 1161 |
ADMIN_CONTENT = """
|
| 1162 |
<div class="row">
|
| 1163 |
-
<div class="col-
|
| 1164 |
<div class="card h-100">
|
| 1165 |
-
<div class="card-header"><h5 class="mb-0">
|
| 1166 |
<div class="card-body">
|
| 1167 |
-
<
|
| 1168 |
-
|
| 1169 |
-
<
|
| 1170 |
-
<
|
| 1171 |
-
<
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
</div>
|
| 1184 |
</div>
|
| 1185 |
</div>
|
|
@@ -1206,6 +1524,20 @@ ADMIN_CONTENT = """
|
|
| 1206 |
</div>
|
| 1207 |
</div>
|
| 1208 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
<div class="col-12 mb-4">
|
| 1210 |
<div class="card">
|
| 1211 |
<div class="card-header"><h5 class="mb-0">Операции по кассе (Внесение/Изъятие)</h5></div>
|
|
@@ -1234,34 +1566,84 @@ ADMIN_CONTENT = """
|
|
| 1234 |
</div>
|
| 1235 |
</form>
|
| 1236 |
<h6>Последние 10 расходов:</h6>
|
| 1237 |
-
<div class="table-responsive">
|
| 1238 |
-
<
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
<
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
</table>
|
| 1251 |
-
</div>
|
| 1252 |
</div>
|
| 1253 |
</div>
|
| 1254 |
</div>
|
| 1255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
<div class="card">
|
| 1257 |
-
<div class="card-header"><h5 class="mb-0">
|
| 1258 |
<div class="card-body">
|
| 1259 |
-
<p>
|
| 1260 |
-
<form method="POST" action="{{ url_for('
|
| 1261 |
-
<
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
<
|
|
|
|
|
|
|
|
|
|
| 1265 |
</form>
|
| 1266 |
</div>
|
| 1267 |
</div>
|
|
@@ -1269,7 +1651,147 @@ ADMIN_CONTENT = """
|
|
| 1269 |
</div>
|
| 1270 |
"""
|
| 1271 |
|
| 1272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1273 |
|
| 1274 |
if __name__ == '__main__':
|
| 1275 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
|
|
|
| 1 |
+
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, session
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import logging
|
|
|
|
| 12 |
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
| 13 |
import functools
|
| 14 |
from collections import defaultdict
|
| 15 |
+
from urllib.parse import quote
|
| 16 |
|
| 17 |
app = Flask(__name__)
|
| 18 |
app.secret_key = os.urandom(24)
|
| 19 |
+
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=8)
|
| 20 |
|
| 21 |
DATA_DIR = 'pos_data'
|
| 22 |
os.makedirs(DATA_DIR, exist_ok=True)
|
|
|
|
| 80 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 81 |
return json.load(f)
|
| 82 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 83 |
+
return [] if file_key != 'users' else [] # Default for most is a list
|
| 84 |
|
| 85 |
def save_json_data(file_key, data):
|
| 86 |
filepath, lock = DATA_FILES[file_key]
|
|
|
|
| 132 |
|
| 133 |
def find_item_by_field(data, field, value):
|
| 134 |
for item in data:
|
| 135 |
+
if isinstance(item, dict) and str(item.get(field)) == str(value):
|
| 136 |
return item
|
| 137 |
return None
|
| 138 |
|
|
|
|
| 145 |
|
| 146 |
@app.context_processor
|
| 147 |
def inject_utils():
|
| 148 |
+
return {'format_currency_py': format_currency_py, 'get_current_time': get_current_time, 'quote': quote}
|
| 149 |
|
| 150 |
@app.route('/')
|
| 151 |
def sales_screen():
|
|
|
|
| 331 |
'kassa_name': kassa.get('name', 'N/A'),
|
| 332 |
'items': sale_items,
|
| 333 |
'total_amount': str(total_amount),
|
| 334 |
+
'payment_method': payment_method,
|
| 335 |
+
'type': 'sale',
|
| 336 |
+
'is_returned': False
|
| 337 |
}
|
| 338 |
|
| 339 |
transactions = load_json_data('transactions')
|
|
|
|
| 379 |
def transaction_history():
|
| 380 |
transactions = load_json_data('transactions')
|
| 381 |
transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 382 |
+
html = BASE_TEMPLATE.replace('__TITLE__', "История транзакций").replace('__CONTENT__', TRANSACTIONS_CONTENT).replace('__SCRIPTS__', TRANSACTIONS_SCRIPTS)
|
| 383 |
return render_template_string(html, transactions=transactions)
|
| 384 |
|
| 385 |
@app.route('/reports')
|
| 386 |
def reports():
|
| 387 |
today = get_current_time().date()
|
| 388 |
start_date_str = request.args.get('start_date', (today.replace(day=1)).strftime('%Y-%m-%d'))
|
| 389 |
+
end_date_str = request.args.get('end_date', today.strftime('%Y-%m-%d'))
|
| 390 |
|
| 391 |
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').replace(tzinfo=BISHKEK_TZ)
|
| 392 |
+
end_date_dt = datetime.strptime(end_date_str, '%Y-%m-%d')
|
| 393 |
+
end_date = (end_date_dt + timedelta(days=1)).replace(tzinfo=BISHKEK_TZ)
|
| 394 |
+
days_in_period = (end_date_dt - start_date.date()).days + 1
|
| 395 |
|
| 396 |
transactions = load_json_data('transactions')
|
| 397 |
expenses = load_json_data('expenses')
|
| 398 |
+
users = load_json_data('users')
|
| 399 |
+
user_map = {u['id']: u for u in users}
|
| 400 |
+
|
| 401 |
+
filtered_transactions = [t for t in transactions if start_date <= datetime.fromisoformat(t['timestamp']) < end_date]
|
| 402 |
+
filtered_expenses = [e for e in expenses if start_date <= datetime.fromisoformat(e['timestamp']) < end_date]
|
| 403 |
|
| 404 |
+
total_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions if t.get('type', 'sale') == 'sale')
|
| 405 |
+
total_returns = sum(to_decimal(t['total_amount']) for t in filtered_transactions if t.get('type') == 'return')
|
| 406 |
+
net_revenue = total_revenue + total_returns
|
| 407 |
+
|
| 408 |
+
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'])
|
| 409 |
+
gross_profit = net_revenue - total_cogs
|
| 410 |
+
|
| 411 |
+
total_manual_expenses = sum(to_decimal(e['amount']) for e in filtered_expenses)
|
| 412 |
|
| 413 |
+
cashier_expenses = Decimal('0.00')
|
| 414 |
+
cashier_expense_details = defaultdict(lambda: {'amount': Decimal('0.00'), 'type': ''})
|
| 415 |
+
salaried_cashiers_in_period = set()
|
| 416 |
+
|
| 417 |
+
for t in filtered_transactions:
|
| 418 |
+
if t.get('type', 'sale') != 'sale': continue
|
| 419 |
+
user = user_map.get(t['user_id'])
|
| 420 |
+
if not user: continue
|
| 421 |
+
|
| 422 |
+
payment_type = user.get('payment_type', 'salary')
|
| 423 |
+
payment_value = to_decimal(user.get('payment_value', '0'))
|
| 424 |
+
|
| 425 |
+
if payment_type == 'percentage':
|
| 426 |
+
commission = to_decimal(t['total_amount']) * (payment_value / 100)
|
| 427 |
+
cashier_expenses += commission
|
| 428 |
+
cashier_expense_details[user['name']]['amount'] += commission
|
| 429 |
+
cashier_expense_details[user['name']]['type'] = f'{payment_value}%'
|
| 430 |
+
elif payment_type == 'salary':
|
| 431 |
+
salaried_cashiers_in_period.add(user['id'])
|
| 432 |
+
|
| 433 |
+
for user_id in salaried_cashiers_in_period:
|
| 434 |
+
user = user_map.get(user_id)
|
| 435 |
+
if not user: continue
|
| 436 |
+
payment_value = to_decimal(user.get('payment_value', '0'))
|
| 437 |
+
daily_salary = payment_value / Decimal('30.44') # Average days in a month
|
| 438 |
+
salary_for_period = daily_salary * Decimal(days_in_period)
|
| 439 |
+
cashier_expenses += salary_for_period
|
| 440 |
+
cashier_expense_details[user['name']]['amount'] += salary_for_period
|
| 441 |
+
cashier_expense_details[user['name']]['type'] = f'Оклад ({format_currency_py(payment_value)}/мес)'
|
| 442 |
+
|
| 443 |
+
total_expenses = total_manual_expenses + cashier_expenses
|
| 444 |
net_profit = gross_profit - total_expenses
|
| 445 |
|
| 446 |
sales_by_cashier = defaultdict(lambda: {'count': 0, 'total': Decimal(0)})
|
| 447 |
for t in filtered_transactions:
|
| 448 |
+
if t.get('type', 'sale') != 'sale': continue
|
| 449 |
cashier_name = t.get('user_name', 'Неизвестный')
|
| 450 |
sales_by_cashier[cashier_name]['count'] += 1
|
| 451 |
sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
|
| 452 |
|
| 453 |
stats = {
|
| 454 |
'total_revenue': total_revenue,
|
| 455 |
+
'total_returns': total_returns,
|
| 456 |
+
'net_revenue': net_revenue,
|
| 457 |
'total_cogs': total_cogs,
|
| 458 |
'gross_profit': gross_profit,
|
| 459 |
+
'total_manual_expenses': total_manual_expenses,
|
| 460 |
+
'cashier_expenses': cashier_expenses,
|
| 461 |
+
'cashier_expense_details': sorted(cashier_expense_details.items(), key=lambda item: item[1]['amount'], reverse=True),
|
| 462 |
'total_expenses': total_expenses,
|
| 463 |
'net_profit': net_profit,
|
| 464 |
'sales_by_cashier': sorted(sales_by_cashier.items(), key=lambda item: item[1]['total'], reverse=True)
|
|
|
|
| 486 |
if action == 'add':
|
| 487 |
name = request.form.get('name', '').strip()
|
| 488 |
pin = request.form.get('pin', '').strip()
|
| 489 |
+
payment_type = request.form.get('payment_type', 'salary')
|
| 490 |
+
payment_value = to_decimal(request.form.get('payment_value', '0'))
|
| 491 |
+
|
| 492 |
if name and pin and pin.isdigit():
|
| 493 |
+
if find_item_by_field(users, 'pin', pin):
|
| 494 |
+
flash(f"ПИН-код {pin} уже используется.", "danger")
|
| 495 |
+
else:
|
| 496 |
+
new_user = {
|
| 497 |
+
'id': uuid.uuid4().hex,
|
| 498 |
+
'name': name,
|
| 499 |
+
'pin': pin,
|
| 500 |
+
'payment_type': payment_type,
|
| 501 |
+
'payment_value': str(payment_value)
|
| 502 |
+
}
|
| 503 |
+
users.append(new_user)
|
| 504 |
+
flash(f"Кассир '{name}' добавлен.", "success")
|
| 505 |
else:
|
| 506 |
flash("Имя и цифровой ПИН-код обязательны.", "danger")
|
| 507 |
|
|
|
|
| 518 |
upload_db_to_hf('users')
|
| 519 |
return redirect(url_for('admin_panel'))
|
| 520 |
|
| 521 |
+
@app.route('/admin/user/edit/<user_id>', methods=['POST'])
|
| 522 |
+
def edit_user(user_id):
|
| 523 |
+
users = load_json_data('users')
|
| 524 |
+
user_found = False
|
| 525 |
+
for i, user in enumerate(users):
|
| 526 |
+
if user.get('id') == user_id:
|
| 527 |
+
name = request.form.get('name', '').strip()
|
| 528 |
+
pin = request.form.get('pin', '').strip()
|
| 529 |
+
payment_type = request.form.get('payment_type', 'salary')
|
| 530 |
+
payment_value = to_decimal(request.form.get('payment_value', '0'))
|
| 531 |
+
|
| 532 |
+
if not name or not pin or not pin.isdigit():
|
| 533 |
+
flash("Имя и цифровой ПИН-код обязательны.", "danger")
|
| 534 |
+
return redirect(url_for('admin_panel'))
|
| 535 |
+
|
| 536 |
+
existing_pin_user = find_item_by_field(users, 'pin', pin)
|
| 537 |
+
if existing_pin_user and existing_pin_user.get('id') != user_id:
|
| 538 |
+
flash(f"ПИН-код {pin} уже используется другим кассиром.", "warning")
|
| 539 |
+
return redirect(url_for('admin_panel'))
|
| 540 |
+
|
| 541 |
+
users[i].update({
|
| 542 |
+
'name': name,
|
| 543 |
+
'pin': pin,
|
| 544 |
+
'payment_type': payment_type,
|
| 545 |
+
'payment_value': str(payment_value)
|
| 546 |
+
})
|
| 547 |
+
user_found = True
|
| 548 |
+
break
|
| 549 |
+
|
| 550 |
+
if user_found:
|
| 551 |
+
save_json_data('users', users)
|
| 552 |
+
upload_db_to_hf('users')
|
| 553 |
+
flash("Данные кассира обновлены.", "success")
|
| 554 |
+
else:
|
| 555 |
+
flash("Кассир не найден.", "danger")
|
| 556 |
+
|
| 557 |
+
return redirect(url_for('admin_panel'))
|
| 558 |
+
|
| 559 |
@app.route('/admin/kassa', methods=['POST'])
|
| 560 |
def manage_kassa():
|
| 561 |
action = request.form.get('action')
|
|
|
|
| 695 |
flash("Произошли ошибки: " + "; ".join(errors), "danger")
|
| 696 |
return redirect(url_for('admin_panel'))
|
| 697 |
|
| 698 |
+
@app.route('/cashier_view', methods=['GET', 'POST'])
|
| 699 |
+
def cashier_view():
|
| 700 |
+
if request.method == 'POST':
|
| 701 |
+
pin = request.form.get('pin')
|
| 702 |
+
users = load_json_data('users')
|
| 703 |
+
user = find_item_by_field(users, 'pin', pin)
|
| 704 |
+
if user:
|
| 705 |
+
session['cashier_id'] = user['id']
|
| 706 |
+
session['cashier_name'] = user['name']
|
| 707 |
+
return redirect(url_for('cashier_sales'))
|
| 708 |
+
else:
|
| 709 |
+
flash('Неверный ПИН-код', 'danger')
|
| 710 |
+
return redirect(url_for('cashier_view'))
|
| 711 |
+
|
| 712 |
+
if 'cashier_id' in session:
|
| 713 |
+
return redirect(url_for('cashier_sales'))
|
| 714 |
+
|
| 715 |
+
html = BASE_TEMPLATE.replace('__TITLE__', "Вход для кассира").replace('__CONTENT__', CASHIER_LOGIN_CONTENT).replace('__SCRIPTS__', '')
|
| 716 |
+
return render_template_string(html)
|
| 717 |
+
|
| 718 |
+
@app.route('/cashier_sales')
|
| 719 |
+
def cashier_sales():
|
| 720 |
+
if 'cashier_id' not in session:
|
| 721 |
+
return redirect(url_for('cashier_view'))
|
| 722 |
+
|
| 723 |
+
user_id = session['cashier_id']
|
| 724 |
+
transactions = load_json_data('transactions')
|
| 725 |
+
user_transactions = [t for t in transactions if t.get('user_id') == user_id]
|
| 726 |
+
user_transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 727 |
+
|
| 728 |
+
html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {session['cashier_name']}").replace('__CONTENT__', CASHIER_SALES_CONTENT).replace('__SCRIPTS__', '')
|
| 729 |
+
return render_template_string(html, transactions=user_transactions)
|
| 730 |
+
|
| 731 |
+
@app.route('/cashier_logout')
|
| 732 |
+
def cashier_logout():
|
| 733 |
+
session.pop('cashier_id', None)
|
| 734 |
+
session.pop('cashier_name', None)
|
| 735 |
+
flash('Вы вышли из своего аккаунта.', 'info')
|
| 736 |
+
return redirect(url_for('cashier_view'))
|
| 737 |
+
|
| 738 |
+
@app.route('/api/process_return', methods=['POST'])
|
| 739 |
+
def process_return():
|
| 740 |
+
if 'cashier_id' not in session:
|
| 741 |
+
return jsonify({'success': False, 'message': 'Требуется авторизация.'}), 401
|
| 742 |
+
|
| 743 |
+
transaction_id = request.form.get('transaction_id')
|
| 744 |
+
return_kassa_id = request.form.get('kassa_id')
|
| 745 |
+
if not transaction_id or not return_kassa_id:
|
| 746 |
+
flash('Не указана транзакция или касса для возврата.', 'danger')
|
| 747 |
+
return redirect(url_for('cashier_sales'))
|
| 748 |
+
|
| 749 |
+
transactions = load_json_data('transactions')
|
| 750 |
+
inventory = load_json_data('inventory')
|
| 751 |
+
kassas = load_json_data('kassas')
|
| 752 |
+
|
| 753 |
+
original_transaction = find_item_by_field(transactions, 'id', transaction_id)
|
| 754 |
+
if not original_transaction:
|
| 755 |
+
flash('Транзакция не найдена.', 'danger')
|
| 756 |
+
return redirect(url_for('cashier_sales'))
|
| 757 |
+
if original_transaction.get('is_returned'):
|
| 758 |
+
flash('Эта продажа уже была возвращена.', 'warning')
|
| 759 |
+
return redirect(url_for('cashier_sales'))
|
| 760 |
+
|
| 761 |
+
return_kassa = find_item_by_field(kassas, 'id', return_kassa_id)
|
| 762 |
+
if not return_kassa:
|
| 763 |
+
flash('Касса для возврата не найдена.', 'danger')
|
| 764 |
+
return redirect(url_for('cashier_sales'))
|
| 765 |
+
|
| 766 |
+
amount_to_return = to_decimal(original_transaction.get('total_amount', '0'))
|
| 767 |
+
kassa_balance = to_decimal(return_kassa.get('balance', '0'))
|
| 768 |
+
if amount_to_return > kassa_balance:
|
| 769 |
+
flash(f"Недостаточно средств в кассе '{return_kassa['name']}' для возврата.", 'danger')
|
| 770 |
+
return redirect(url_for('cashier_sales'))
|
| 771 |
+
|
| 772 |
+
now_iso = get_current_time().isoformat()
|
| 773 |
+
return_items = []
|
| 774 |
+
inventory_updates = {}
|
| 775 |
+
for item in original_transaction.get('items', []):
|
| 776 |
+
return_items.append({**item, 'quantity': -item['quantity'], 'total': str(-to_decimal(item['total']))})
|
| 777 |
+
inventory_updates[item['product_id']] = item['quantity']
|
| 778 |
+
|
| 779 |
+
new_return = {
|
| 780 |
+
'id': uuid.uuid4().hex,
|
| 781 |
+
'timestamp': now_iso,
|
| 782 |
+
'user_id': session['cashier_id'],
|
| 783 |
+
'user_name': session['cashier_name'],
|
| 784 |
+
'kassa_id': return_kassa_id,
|
| 785 |
+
'kassa_name': return_kassa['name'],
|
| 786 |
+
'items': return_items,
|
| 787 |
+
'total_amount': str(-amount_to_return),
|
| 788 |
+
'payment_method': 'return',
|
| 789 |
+
'type': 'return',
|
| 790 |
+
'original_transaction_id': transaction_id
|
| 791 |
+
}
|
| 792 |
+
transactions.append(new_return)
|
| 793 |
+
|
| 794 |
+
for i, t in enumerate(transactions):
|
| 795 |
+
if t['id'] == transaction_id:
|
| 796 |
+
transactions[i]['is_returned'] = True
|
| 797 |
+
break
|
| 798 |
+
|
| 799 |
+
for prod_id, qty in inventory_updates.items():
|
| 800 |
+
for i, p in enumerate(inventory):
|
| 801 |
+
if p['id'] == prod_id:
|
| 802 |
+
inventory[i]['stock'] = inventory[i].get('stock', 0) + qty
|
| 803 |
+
inventory[i]['timestamp_updated'] = now_iso
|
| 804 |
+
break
|
| 805 |
+
|
| 806 |
+
for i, k in enumerate(kassas):
|
| 807 |
+
if k['id'] == return_kassa_id:
|
| 808 |
+
kassas[i]['balance'] = str(kassa_balance - amount_to_return)
|
| 809 |
+
if 'history' not in kassas[i]: kassas[i]['history'] = []
|
| 810 |
+
kassas[i]['history'].append({
|
| 811 |
+
'type': 'return',
|
| 812 |
+
'amount': str(-amount_to_return),
|
| 813 |
+
'timestamp': now_iso,
|
| 814 |
+
'transaction_id': new_return['id']
|
| 815 |
+
})
|
| 816 |
+
break
|
| 817 |
+
|
| 818 |
+
save_json_data('transactions', transactions)
|
| 819 |
+
save_json_data('inventory', inventory)
|
| 820 |
+
save_json_data('kassas', kassas)
|
| 821 |
+
upload_db_to_hf('transactions')
|
| 822 |
+
upload_db_to_hf('inventory')
|
| 823 |
+
upload_db_to_hf('kassas')
|
| 824 |
+
|
| 825 |
+
flash('Возврат успешно оформлен.', 'success')
|
| 826 |
+
return redirect(url_for('cashier_sales'))
|
| 827 |
+
|
| 828 |
+
@app.route('/receipt/<transaction_id>')
|
| 829 |
+
def receipt(transaction_id):
|
| 830 |
+
transactions = load_json_data('transactions')
|
| 831 |
+
transaction = find_item_by_field(transactions, 'id', transaction_id)
|
| 832 |
+
if not transaction:
|
| 833 |
+
return "Чек не найден", 404
|
| 834 |
+
return render_template_string(RECEIPT_TEMPLATE, transaction=transaction)
|
| 835 |
+
|
| 836 |
BASE_TEMPLATE = """
|
| 837 |
<!DOCTYPE html>
|
| 838 |
<html lang="ru" data-bs-theme="light">
|
|
|
|
| 856 |
}
|
| 857 |
[data-bs-theme="dark"] body { background-color: #212529; color: #dee2e6; }
|
| 858 |
[data-bs-theme="dark"] .card, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table { background-color: #343a40; }
|
| 859 |
+
[data-bs-theme="dark"] .modal-content { background-color: #2b3035; }
|
| 860 |
[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); }
|
| 861 |
</style>
|
| 862 |
</head>
|
|
|
|
| 868 |
<ul class="nav flex-column">
|
| 869 |
<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>
|
| 870 |
<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>
|
| 871 |
+
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_view', 'cashier_sales'] %}active{% endif %}" href="{{ url_for('cashier_view') }}"><i class="fas fa-fw fa-user-check me-2"></i>Мои продажи</a></li>
|
| 872 |
+
<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>
|
| 873 |
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'reports' %}active{% endif %}" href="{{ url_for('reports') }}"><i class="fas fa-fw fa-chart-line me-2"></i>Отчеты</a></li>
|
| 874 |
<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>
|
| 875 |
</ul>
|
|
|
|
| 974 |
</div>
|
| 975 |
</div>
|
| 976 |
</div>
|
| 977 |
+
|
| 978 |
+
<div class="modal fade" id="saleSuccessModal" tabindex="-1">
|
| 979 |
+
<div class="modal-dialog">
|
| 980 |
+
<div class="modal-content">
|
| 981 |
+
<div class="modal-header">
|
| 982 |
+
<h5 class="modal-title text-success"><i class="fas fa-check-circle me-2"></i>Продажа завершена</h5>
|
| 983 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
| 984 |
+
</div>
|
| 985 |
+
<div class="modal-body">
|
| 986 |
+
<p>Чек успешно создан.</p>
|
| 987 |
+
<a href="#" id="receiptLink" target="_blank" class="btn btn-primary w-100 mb-3"><i class="fas fa-receipt me-2"></i>Посмотреть чек</a>
|
| 988 |
+
<div class="mb-3">
|
| 989 |
+
<label for="whatsapp-number" class="form-label">Отправить на WhatsApp:</label>
|
| 990 |
+
<div class="input-group">
|
| 991 |
+
<span class="input-group-text">+996</span>
|
| 992 |
+
<input type="tel" class="form-control" id="whatsapp-number" placeholder="555123456">
|
| 993 |
+
</div>
|
| 994 |
+
</div>
|
| 995 |
+
<button id="send-whatsapp-btn" class="btn btn-success w-100"><i class="fab fa-whatsapp me-2"></i>Отправить</button>
|
| 996 |
+
</div>
|
| 997 |
+
</div>
|
| 998 |
+
</div>
|
| 999 |
+
</div>
|
| 1000 |
"""
|
| 1001 |
|
| 1002 |
SALES_SCREEN_SCRIPTS = """
|
|
|
|
| 1008 |
const cartTotalEl = document.getElementById('cart-total');
|
| 1009 |
let audioCtx;
|
| 1010 |
let isScannerPaused = false;
|
| 1011 |
+
const saleSuccessModal = new bootstrap.Modal(document.getElementById('saleSuccessModal'));
|
| 1012 |
|
| 1013 |
function playBeep() {
|
| 1014 |
if (!audioCtx) {
|
|
|
|
| 1125 |
.then(res => res.json())
|
| 1126 |
.then(data => {
|
| 1127 |
if (data.success) {
|
| 1128 |
+
document.getElementById('receiptLink').href = `/receipt/${data.transactionId}`;
|
| 1129 |
+
document.getElementById('whatsapp-number').value = '';
|
| 1130 |
+
saleSuccessModal.show();
|
| 1131 |
+
|
| 1132 |
for(const id in cart) delete cart[id];
|
| 1133 |
updateCartView();
|
| 1134 |
} else {
|
|
|
|
| 1141 |
document.getElementById('pay-cash-btn').addEventListener('click', () => completeSale('cash'));
|
| 1142 |
document.getElementById('pay-card-btn').addEventListener('click', () => completeSale('card'));
|
| 1143 |
|
| 1144 |
+
document.getElementById('send-whatsapp-btn').addEventListener('click', () => {
|
| 1145 |
+
const phoneInput = document.getElementById('whatsapp-number');
|
| 1146 |
+
let phone = phoneInput.value.replace(/\\D/g, '');
|
| 1147 |
+
if (phone.length === 9) { // Expecting 9 digits for KG mobile
|
| 1148 |
+
const fullPhone = '996' + phone;
|
| 1149 |
+
const receiptUrl = document.getElementById('receiptLink').href;
|
| 1150 |
+
const message = encodeURIComponent(`Спасибо за покупку! Ваш электронный чек: ${receiptUrl}`);
|
| 1151 |
+
window.open(`https://wa.me/${fullPhone}?text=${message}`, '_blank');
|
| 1152 |
+
} else {
|
| 1153 |
+
alert('Пожалуйста, введите корректный 9-значный номер телефона (без кода страны).');
|
| 1154 |
+
phoneInput.focus();
|
| 1155 |
+
}
|
| 1156 |
+
});
|
| 1157 |
+
|
| 1158 |
const html5QrCode = new Html5Qrcode("reader");
|
| 1159 |
const scannerStatusEl = document.getElementById('scanner-status');
|
| 1160 |
|
|
|
|
| 1283 |
<script>
|
| 1284 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1285 |
let currentScanner = null;
|
| 1286 |
+
let currentScannerContainer = null;
|
| 1287 |
document.querySelectorAll('.scan-modal-btn').forEach(btn => {
|
| 1288 |
btn.addEventListener('click', e => {
|
| 1289 |
const form = e.target.closest('form');
|
| 1290 |
const scannerContainer = form.querySelector('[id^="modal-scanner-"]');
|
| 1291 |
const barcodeInput = form.querySelector('.barcode-input');
|
| 1292 |
+
|
| 1293 |
+
if (currentScanner && currentScannerContainer === scannerContainer) {
|
| 1294 |
currentScanner.stop();
|
| 1295 |
currentScanner = null;
|
| 1296 |
+
currentScannerContainer.style.display = 'none';
|
| 1297 |
+
currentScannerContainer = null;
|
| 1298 |
return;
|
| 1299 |
+
} else if (currentScanner) {
|
| 1300 |
+
currentScanner.stop();
|
| 1301 |
+
currentScannerContainer.style.display = 'none';
|
| 1302 |
}
|
| 1303 |
+
|
| 1304 |
scannerContainer.style.display = 'block';
|
| 1305 |
const scannerId = scannerContainer.id + '-reader';
|
| 1306 |
+
if(!document.getElementById(scannerId)) scannerContainer.innerHTML = `<div id="${scannerId}" style="width: 100%;"></div>`;
|
| 1307 |
|
| 1308 |
const html5QrCode = new Html5Qrcode(scannerId);
|
| 1309 |
currentScanner = html5QrCode;
|
| 1310 |
+
currentScannerContainer = scannerContainer;
|
| 1311 |
const onScanSuccess = (decodedText, decodedResult) => {
|
| 1312 |
barcodeInput.value = decodedText;
|
| 1313 |
+
html5QrCode.stop().then(() => {
|
| 1314 |
+
scannerContainer.style.display = 'none';
|
| 1315 |
+
currentScanner = null;
|
| 1316 |
+
currentScannerContainer = null;
|
| 1317 |
+
});
|
| 1318 |
};
|
| 1319 |
+
html5QrCode.start({ facingMode: "environment" }, { fps: 10, qrbox: {width: 250, height: 150} }, onScanSuccess)
|
| 1320 |
+
.catch(err => { console.error("Scanner start failed", err); });
|
| 1321 |
});
|
| 1322 |
});
|
| 1323 |
|
|
|
|
| 1326 |
if (currentScanner) {
|
| 1327 |
currentScanner.stop();
|
| 1328 |
currentScanner = null;
|
| 1329 |
+
if(currentScannerContainer) currentScannerContainer.style.display = 'none';
|
| 1330 |
+
currentScannerContainer = null;
|
| 1331 |
}
|
|
|
|
| 1332 |
});
|
| 1333 |
});
|
| 1334 |
});
|
|
|
|
| 1340 |
<div class="card-body">
|
| 1341 |
<div class="table-responsive">
|
| 1342 |
<table class="table table-sm table-hover">
|
| 1343 |
+
<thead><tr><th>ID</th><th>Дата</th><th>Тип</th><th>Кассир</th><th>Касса</th><th>Сумма</th><th>Тип опл.</th><th>Позиции</th></tr></thead>
|
| 1344 |
<tbody>
|
| 1345 |
{% for t in transactions %}
|
| 1346 |
+
<tr class="{{ 'table-danger' if t.type == 'return' else '' }}">
|
| 1347 |
<td><small class="text-muted">{{ t.id[:8] }}</small></td>
|
| 1348 |
<td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
|
| 1349 |
+
<td>
|
| 1350 |
+
{% if t.type == 'return' %}
|
| 1351 |
+
<span class="badge bg-danger">Возврат</span>
|
| 1352 |
+
{% else %}
|
| 1353 |
+
<span class="badge bg-success">Продажа</span>
|
| 1354 |
+
{% endif %}
|
| 1355 |
+
</td>
|
| 1356 |
<td>{{ t.user_name }}</td><td>{{ t.kassa_name }}</td>
|
| 1357 |
<td class="fw-bold">{{ format_currency_py(t.total_amount) }} с</td>
|
| 1358 |
+
<td>
|
| 1359 |
+
{% if t.payment_method == 'cash' %} <span class="badge bg-success">Нал.</span>
|
| 1360 |
+
{% elif t.payment_method == 'card' %} <span class="badge bg-info">Карта</span>
|
| 1361 |
+
{% elif t.payment_method == 'return' %} <span class="badge bg-warning text-dark">Возврат</span>
|
| 1362 |
+
{% endif %}
|
| 1363 |
+
</td>
|
| 1364 |
<td>
|
| 1365 |
<ul class="list-unstyled mb-0 small">
|
| 1366 |
{% for item in t['items'] %}<li>{{ item.name }} ({{ item.quantity }}x{{ format_currency_py(item.price_at_sale) }})</li>{% endfor %}
|
|
|
|
| 1396 |
<div class="card-header"><h5 class="mb-0">Сводный отчет за период</h5></div>
|
| 1397 |
<div class="card-body">
|
| 1398 |
<ul class="list-group list-group-flush">
|
| 1399 |
+
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-arrow-up me-2 text-primary"></i>Выручка (сумма продаж)</span> <strong>{{ format_currency_py(stats.total_revenue) }} с</strong></li>
|
| 1400 |
+
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-arrow-down me-2 text-warning"></i>Сумма возвратов</span> <strong>{{ format_currency_py(stats.total_returns) }} с</strong></li>
|
| 1401 |
+
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-equals me-2 text-info"></i>Чистая выручка</span> <strong class="text-info">{{ format_currency_py(stats.net_revenue) }} с</strong></li>
|
| 1402 |
+
<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>
|
| 1403 |
<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>
|
| 1404 |
+
<li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-receipt me-2 text-danger"></i>Операционные расходы</span> <strong class="text-danger">-{{ format_currency_py(stats.total_manual_expenses) }} с</strong></li>
|
| 1405 |
+
<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.cashier_expenses) }} с</strong></li>
|
| 1406 |
<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>
|
| 1407 |
</ul>
|
| 1408 |
</div>
|
| 1409 |
</div>
|
| 1410 |
<div class="card mb-4">
|
| 1411 |
+
<div class="card-header"><h5 class="mb-0">Расходы на кассиров (детализация)</h5></div>
|
| 1412 |
<div class="card-body p-0">
|
| 1413 |
+
<div class="table-responsive"><table class="table table-sm mb-0">
|
| 1414 |
+
<thead><tr><th>Кассир</th><th>Тип</th><th class="text-end">Сумма</th></tr></thead>
|
| 1415 |
+
<tbody>
|
| 1416 |
+
{% for name, data in stats.cashier_expense_details %}
|
| 1417 |
+
<tr><td>{{ name }}</td><td>{{ data.type }}</td><td class="text-end">{{ format_currency_py(data.amount) }} с</td></tr>
|
| 1418 |
+
{% else %}
|
| 1419 |
+
<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>
|
| 1420 |
+
{% endfor %}
|
| 1421 |
+
</tbody>
|
| 1422 |
+
</table></div>
|
| 1423 |
+
</div>
|
| 1424 |
+
</div>
|
| 1425 |
+
<div class="card mb-4">
|
| 1426 |
+
<div class="card-header"><h5 class="mb-0">Операционные расходы за период</h5></div>
|
| 1427 |
+
<div class="card-body p-0">
|
| 1428 |
+
<div class="table-responsive"><table class="table table-sm mb-0">
|
| 1429 |
<thead><tr><th>Дата</th><th>Описание</th><th class="text-end">Сумма</th></tr></thead>
|
| 1430 |
<tbody>
|
| 1431 |
{% for expense in expenses %}
|
|
|
|
| 1438 |
<tr><td colspan="3" class="text-center text-muted">Нет расходов за выбранный период.</td></tr>
|
| 1439 |
{% endfor %}
|
| 1440 |
</tbody>
|
| 1441 |
+
</table></div>
|
|
|
|
| 1442 |
</div>
|
| 1443 |
</div>
|
| 1444 |
</div>
|
|
|
|
| 1446 |
<div class="card mb-4">
|
| 1447 |
<div class="card-header"><h5 class="mb-0">Продажи по кассирам</h5></div>
|
| 1448 |
<div class="card-body p-0">
|
| 1449 |
+
<div class="table-responsive"><table class="table table-hover mb-0">
|
|
|
|
| 1450 |
<thead><tr><th>Кассир</th><th>Чеков</th><th class="text-end">Сумма</th></tr></thead>
|
| 1451 |
<tbody>
|
| 1452 |
{% for name, data in stats.sales_by_cashier %}
|
|
|
|
| 1459 |
<tr><td colspan="3" class="text-center text-muted">Нет продаж за выбранный период.</td></tr>
|
| 1460 |
{% endfor %}
|
| 1461 |
</tbody>
|
| 1462 |
+
</table></div>
|
|
|
|
| 1463 |
</div>
|
| 1464 |
</div>
|
| 1465 |
</div>
|
|
|
|
| 1470 |
|
| 1471 |
ADMIN_CONTENT = """
|
| 1472 |
<div class="row">
|
| 1473 |
+
<div class="col-12 mb-4">
|
| 1474 |
<div class="card h-100">
|
| 1475 |
+
<div class="card-header"><h5 class="mb-0">Управление кассирами</h5></div>
|
| 1476 |
<div class="card-body">
|
| 1477 |
+
<button class="btn btn-primary mb-3" data-bs-toggle="modal" data-bs-target="#addUserModal"><i class="fas fa-plus me-2"></i>Добавить кассира</button>
|
| 1478 |
+
<div class="table-responsive">
|
| 1479 |
+
<table class="table table-hover">
|
| 1480 |
+
<thead><tr><th>Имя</th><th>ПИН-код</th><th>Тип оплаты</th><th>Ставка/Зарплата</th><th>Действия</th></tr></thead>
|
| 1481 |
+
<tbody>
|
| 1482 |
+
{% for u in users %}
|
| 1483 |
+
<tr>
|
| 1484 |
+
<td>{{ u.name }}</td>
|
| 1485 |
+
<td>****</td>
|
| 1486 |
+
<td>{{ 'Оклад' if u.get('payment_type') == 'salary' else 'Процент' }}</td>
|
| 1487 |
+
<td>{{ format_currency_py(u.get('payment_value', '0')) }}{{ ' с' if u.get('payment_type') == 'salary' else '%' }}</td>
|
| 1488 |
+
<td>
|
| 1489 |
+
<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>
|
| 1490 |
+
<form class="d-inline" action="{{ url_for('manage_user') }}" method="POST" onsubmit="return confirm('Удалить кассира?');">
|
| 1491 |
+
<input type="hidden" name="action" value="delete">
|
| 1492 |
+
<input type="hidden" name="id" value="{{ u.id }}">
|
| 1493 |
+
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
| 1494 |
+
</form>
|
| 1495 |
+
</td>
|
| 1496 |
+
</tr>
|
| 1497 |
+
{% endfor %}
|
| 1498 |
+
</tbody>
|
| 1499 |
+
</table>
|
| 1500 |
+
</div>
|
| 1501 |
</div>
|
| 1502 |
</div>
|
| 1503 |
</div>
|
|
|
|
| 1524 |
</div>
|
| 1525 |
</div>
|
| 1526 |
</div>
|
| 1527 |
+
<div class="col-md-6 mb-4">
|
| 1528 |
+
<div class="card">
|
| 1529 |
+
<div class="card-header"><h5 class="mb-0">Резервное копирование</h5></div>
|
| 1530 |
+
<div class="card-body">
|
| 1531 |
+
<p>Данные периодически сохраняются в облако. Можно сделать это вручную.</p>
|
| 1532 |
+
<form method="POST" action="{{ url_for('backup_hf') }}" class="d-inline-block me-2">
|
| 1533 |
+
<button type="submit" class="btn btn-outline-primary"><i class="fas fa-cloud-upload-alt me-2"></i>Сохранить в облако</button>
|
| 1534 |
+
</form>
|
| 1535 |
+
<form method="GET" action="{{ url_for('download_hf') }}" class="d-inline-block" onsubmit="return confirm('ВНИМАНИЕ! Это действие заменит все локальные данные данными из облака. Продолжить?');">
|
| 1536 |
+
<button type="submit" class="btn btn-outline-danger"><i class="fas fa-cloud-download-alt me-2"></i>Загрузить из облака</button>
|
| 1537 |
+
</form>
|
| 1538 |
+
</div>
|
| 1539 |
+
</div>
|
| 1540 |
+
</div>
|
| 1541 |
<div class="col-12 mb-4">
|
| 1542 |
<div class="card">
|
| 1543 |
<div class="card-header"><h5 class="mb-0">Операции по кассе (Внесение/Изъятие)</h5></div>
|
|
|
|
| 1566 |
</div>
|
| 1567 |
</form>
|
| 1568 |
<h6>Последние 10 расходов:</h6>
|
| 1569 |
+
<div class="table-responsive"><table class="table table-sm table-striped">
|
| 1570 |
+
<tbody>
|
| 1571 |
+
{% for e in expenses %}
|
| 1572 |
+
<tr>
|
| 1573 |
+
<td><small>{{ e.timestamp[:16]|replace('T', ' ') }}</small></td>
|
| 1574 |
+
<td>{{ e.description }}</td>
|
| 1575 |
+
<td class="text-end fw-bold">{{ format_currency_py(e.amount) }} с</td>
|
| 1576 |
+
</tr>
|
| 1577 |
+
{% else %}
|
| 1578 |
+
<tr><td colspan="3" class="text-center">Расходов пока нет</td></tr>
|
| 1579 |
+
{% endfor %}
|
| 1580 |
+
</tbody>
|
| 1581 |
+
</table></div>
|
|
|
|
|
|
|
| 1582 |
</div>
|
| 1583 |
</div>
|
| 1584 |
</div>
|
| 1585 |
+
</div>
|
| 1586 |
+
|
| 1587 |
+
<div class="modal fade" id="addUserModal" tabindex="-1">
|
| 1588 |
+
<div class="modal-dialog">
|
| 1589 |
+
<div class="modal-content">
|
| 1590 |
+
<div class="modal-header"><h5 class="modal-title">Новый кассир</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
| 1591 |
+
<form action="{{ url_for('manage_user') }}" method="POST">
|
| 1592 |
+
<input type="hidden" name="action" value="add">
|
| 1593 |
+
<div class="modal-body">
|
| 1594 |
+
<div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" class="form-control" required></div>
|
| 1595 |
+
<div class="mb-3"><label class="form-label">ПИН-код (цифры)</label><input type="password" name="pin" class="form-control" required inputmode="numeric" pattern="[0-9]*"></div>
|
| 1596 |
+
<div class="mb-3"><label class="form-label">Тип оплаты</label><select name="payment_type" class="form-select"><option value="salary">Оклад</option><option value="percentage">Процент от продаж</option></select></div>
|
| 1597 |
+
<div class="mb-3"><label class="form-label">Ставка / Зарплата</label><input type="text" name="payment_value" class="form-control" value="0" inputmode="decimal"></div>
|
| 1598 |
+
</div>
|
| 1599 |
+
<div class="modal-footer"><button type="submit" class="btn btn-primary">Добавить</button></div>
|
| 1600 |
+
</form>
|
| 1601 |
+
</div>
|
| 1602 |
+
</div>
|
| 1603 |
+
</div>
|
| 1604 |
+
|
| 1605 |
+
{% for u in users %}
|
| 1606 |
+
<div class="modal fade" id="editUserModal-{{ u.id }}" tabindex="-1">
|
| 1607 |
+
<div class="modal-dialog">
|
| 1608 |
+
<div class="modal-content">
|
| 1609 |
+
<div class="modal-header"><h5 class="modal-title">Редактировать кассира</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
| 1610 |
+
<form action="{{ url_for('edit_user', user_id=u.id) }}" method="POST">
|
| 1611 |
+
<div class="modal-body">
|
| 1612 |
+
<div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" class="form-control" value="{{ u.name }}" required></div>
|
| 1613 |
+
<div class="mb-3"><label class="form-label">Новый ПИН-код (оставьте пустым, чтобы не менять)</label><input type="password" name="pin" value="{{ u.pin }}" class="form-control" required inputmode="numeric" pattern="[0-9]*"></div>
|
| 1614 |
+
<div class="mb-3"><label class="form-label">Тип оплаты</label>
|
| 1615 |
+
<select name="payment_type" class="form-select">
|
| 1616 |
+
<option value="salary" {% if u.get('payment_type') == 'salary' %}selected{% endif %}>Оклад</option>
|
| 1617 |
+
<option value="percentage" {% if u.get('payment_type') == 'percentage' %}selected{% endif %}>Процент от продаж</option>
|
| 1618 |
+
</select>
|
| 1619 |
+
</div>
|
| 1620 |
+
<div class="mb-3"><label class="form-label">Ставка / Зарплата</label><input type="text" name="payment_value" class="form-control" value="{{ u.get('payment_value', '0')|string|replace('.', ',') }}" inputmode="decimal"></div>
|
| 1621 |
+
</div>
|
| 1622 |
+
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
|
| 1623 |
+
</form>
|
| 1624 |
+
</div>
|
| 1625 |
+
</div>
|
| 1626 |
+
</div>
|
| 1627 |
+
{% endfor %}
|
| 1628 |
+
"""
|
| 1629 |
+
|
| 1630 |
+
ADMIN_SCRIPTS = ""
|
| 1631 |
+
|
| 1632 |
+
CASHIER_LOGIN_CONTENT = """
|
| 1633 |
+
<div class="row justify-content-center">
|
| 1634 |
+
<div class="col-md-6 col-lg-4">
|
| 1635 |
<div class="card">
|
| 1636 |
+
<div class="card-header"><h5 class="mb-0">Вход для кассира</h5></div>
|
| 1637 |
<div class="card-body">
|
| 1638 |
+
<p class="text-muted">Введите ваш персональный ПИН-код для доступа к вашим продажам и для оформления возвратов.</p>
|
| 1639 |
+
<form method="POST" action="{{ url_for('cashier_view') }}">
|
| 1640 |
+
<div class="mb-3">
|
| 1641 |
+
<label for="pin" class="form-label">ПИН-код</label>
|
| 1642 |
+
<input type="password" id="pin" name="pin" class="form-control form-control-lg text-center" required inputmode="numeric" pattern="[0-9]*" autofocus>
|
| 1643 |
+
</div>
|
| 1644 |
+
<div class="d-grid">
|
| 1645 |
+
<button type="submit" class="btn btn-primary btn-lg">Войти</button>
|
| 1646 |
+
</div>
|
| 1647 |
</form>
|
| 1648 |
</div>
|
| 1649 |
</div>
|
|
|
|
| 1651 |
</div>
|
| 1652 |
"""
|
| 1653 |
|
| 1654 |
+
CASHIER_SALES_CONTENT = """
|
| 1655 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 1656 |
+
<h4 class="mb-0">Ваши транзакции</h4>
|
| 1657 |
+
<a href="{{ url_for('cashier_logout') }}" class="btn btn-sm btn-outline-secondary">Выйти</a>
|
| 1658 |
+
</div>
|
| 1659 |
+
<div class="card">
|
| 1660 |
+
<div class="card-body">
|
| 1661 |
+
<div class="table-responsive">
|
| 1662 |
+
<table class="table table-sm table-hover">
|
| 1663 |
+
<thead><tr><th>ID</th><th>Дата</th><th>Сумма</th><th>Статус</th><th>Действия</th></tr></thead>
|
| 1664 |
+
<tbody>
|
| 1665 |
+
{% set kassas = load_json_data('kassas') %}
|
| 1666 |
+
{% for t in transactions %}
|
| 1667 |
+
{% if t.type == 'sale' %}
|
| 1668 |
+
<tr>
|
| 1669 |
+
<td><small class="text-muted">{{ t.id[:8] }}</small></td>
|
| 1670 |
+
<td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
|
| 1671 |
+
<td class="fw-bold">{{ format_currency_py(t.total_amount) }} с</td>
|
| 1672 |
+
<td>
|
| 1673 |
+
{% if t.is_returned %}
|
| 1674 |
+
<span class="badge bg-warning text-dark">Возвращен</span>
|
| 1675 |
+
{% else %}
|
| 1676 |
+
<span class="badge bg-success">Продан</span>
|
| 1677 |
+
{% endif %}
|
| 1678 |
+
</td>
|
| 1679 |
+
<td>
|
| 1680 |
+
<button class="btn btn-sm btn-danger"
|
| 1681 |
+
data-bs-toggle="modal"
|
| 1682 |
+
data-bs-target="#returnModal-{{ t.id }}"
|
| 1683 |
+
{% if t.is_returned %}disabled{% endif %}>
|
| 1684 |
+
<i class="fas fa-undo me-1"></i> Возврат
|
| 1685 |
+
</button>
|
| 1686 |
+
</td>
|
| 1687 |
+
</tr>
|
| 1688 |
+
{% endif %}
|
| 1689 |
+
{% endfor %}
|
| 1690 |
+
</tbody>
|
| 1691 |
+
</table>
|
| 1692 |
+
</div>
|
| 1693 |
+
</div>
|
| 1694 |
+
</div>
|
| 1695 |
+
|
| 1696 |
+
{% for t in transactions %}
|
| 1697 |
+
<div class="modal fade" id="returnModal-{{ t.id }}" tabindex="-1">
|
| 1698 |
+
<div class="modal-dialog">
|
| 1699 |
+
<div class="modal-content">
|
| 1700 |
+
<div class="modal-header"><h5 class="modal-title">Оформление возврата</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
| 1701 |
+
<form action="{{ url_for('api_process_return') }}" method="POST" onsubmit="return confirm('Вы уверены, что хотите оформить возврат на сумму {{ format_currency_py(t.total_amount) }} с? Это действие нельзя отменить.');">
|
| 1702 |
+
<div class="modal-body">
|
| 1703 |
+
<p>Вы собираетесь оформить возврат для транзакции <strong>#{{ t.id[:8] }}</strong> от {{ t.timestamp[:10] }}.</p>
|
| 1704 |
+
<p>Сумма к возврату: <strong class="fs-5">{{ format_currency_py(t.total_amount) }} с</strong>.</p>
|
| 1705 |
+
<input type="hidden" name="transaction_id" value="{{ t.id }}">
|
| 1706 |
+
<div class="mb-3">
|
| 1707 |
+
<label for="kassa_id_{{t.id}}" class="form-label">Из какой кассы вернуть средства?</label>
|
| 1708 |
+
<select id="kassa_id_{{t.id}}" name="kassa_id" class="form-select" required>
|
| 1709 |
+
<option value="">-- Выберите кассу --</option>
|
| 1710 |
+
{% for k in kassas %}
|
| 1711 |
+
<option value="{{ k.id }}">{{ k.name }} (Баланс: {{ format_currency_py(k.balance) }} с)</option>
|
| 1712 |
+
{% endfor %}
|
| 1713 |
+
</select>
|
| 1714 |
+
</div>
|
| 1715 |
+
<p class="small text-muted">Средства будут списаны с баланса выбранной кассы, а товары будут возвращены на склад.</p>
|
| 1716 |
+
</div>
|
| 1717 |
+
<div class="modal-footer">
|
| 1718 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
| 1719 |
+
<button type="submit" class="btn btn-danger">Подтвердить возврат</button>
|
| 1720 |
+
</div>
|
| 1721 |
+
</form>
|
| 1722 |
+
</div>
|
| 1723 |
+
</div>
|
| 1724 |
+
</div>
|
| 1725 |
+
{% endfor %}
|
| 1726 |
+
"""
|
| 1727 |
+
|
| 1728 |
+
RECEIPT_TEMPLATE = """
|
| 1729 |
+
<!DOCTYPE html>
|
| 1730 |
+
<html lang="ru">
|
| 1731 |
+
<head>
|
| 1732 |
+
<meta charset="UTF-8">
|
| 1733 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1734 |
+
<title>Чек #{{ transaction.id[:8] }}</title>
|
| 1735 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 1736 |
+
<style>
|
| 1737 |
+
body { background-color: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
| 1738 |
+
.receipt-container { max-width: 400px; margin: 2rem auto; background: #fff; border: 1px solid #ddd; padding: 1.5rem; box-shadow: 0 0 10px rgba(0,0,0,0.05); }
|
| 1739 |
+
.receipt-header { text-align: center; border-bottom: 2px dashed #ccc; padding-bottom: 1rem; margin-bottom: 1rem; }
|
| 1740 |
+
.receipt-header h3 { margin-bottom: 0.25rem; }
|
| 1741 |
+
.receipt-items table { width: 100%; font-size: 0.9rem; }
|
| 1742 |
+
.receipt-items th, .receipt-items td { padding: 0.25rem 0; }
|
| 1743 |
+
.receipt-items thead th { border-bottom: 1px solid #eee; text-align: left; }
|
| 1744 |
+
.receipt-items .price { text-align: right; }
|
| 1745 |
+
.receipt-total { border-top: 2px dashed #ccc; margin-top: 1rem; padding-top: 1rem; }
|
| 1746 |
+
.receipt-total .total-row { display: flex; justify-content: space-between; font-weight: bold; font-size: 1.2rem; }
|
| 1747 |
+
.receipt-footer { text-align: center; margin-top: 1.5rem; font-size: 0.8rem; color: #777; }
|
| 1748 |
+
@media print {
|
| 1749 |
+
body { background-color: #fff; }
|
| 1750 |
+
.receipt-container { margin: 0; box-shadow: none; border: none; }
|
| 1751 |
+
}
|
| 1752 |
+
</style>
|
| 1753 |
+
</head>
|
| 1754 |
+
<body>
|
| 1755 |
+
<div class="receipt-container">
|
| 1756 |
+
<div class="receipt-header">
|
| 1757 |
+
<h3>Магазин "Kgshop"</h3>
|
| 1758 |
+
<p class="mb-0">Товарный чек #{{ transaction.id[:8] }}</p>
|
| 1759 |
+
<p class="text-muted small mb-0">{{ transaction.timestamp[:16]|replace('T', ' ') }}</p>
|
| 1760 |
+
</div>
|
| 1761 |
+
<div class="receipt-items">
|
| 1762 |
+
<table>
|
| 1763 |
+
<thead>
|
| 1764 |
+
<tr><th>Товар</th><th>Кол-во</th><th class="price">Цена</th><th class="price">Сумма</th></tr>
|
| 1765 |
+
</thead>
|
| 1766 |
+
<tbody>
|
| 1767 |
+
{% for item in transaction.items %}
|
| 1768 |
+
<tr>
|
| 1769 |
+
<td>{{ item.name }}</td>
|
| 1770 |
+
<td>{{ item.quantity }}</td>
|
| 1771 |
+
<td class="price">{{ format_currency_py(item.price_at_sale) }}</td>
|
| 1772 |
+
<td class="price">{{ format_currency_py(item.total) }}</td>
|
| 1773 |
+
</tr>
|
| 1774 |
+
{% endfor %}
|
| 1775 |
+
</tbody>
|
| 1776 |
+
</table>
|
| 1777 |
+
</div>
|
| 1778 |
+
<div class="receipt-total">
|
| 1779 |
+
<div class="total-row">
|
| 1780 |
+
<span>ИТОГО:</span>
|
| 1781 |
+
<span>{{ format_currency_py(transaction.total_amount) }} с</span>
|
| 1782 |
+
</div>
|
| 1783 |
+
<p class="text-muted small mt-2 mb-0">
|
| 1784 |
+
Способ оплаты: {{ 'Наличные' if transaction.payment_method == 'cash' else 'Карта' }} <br>
|
| 1785 |
+
Кассир: {{ transaction.user_name }}
|
| 1786 |
+
</p>
|
| 1787 |
+
</div>
|
| 1788 |
+
<div class="receipt-footer">
|
| 1789 |
+
<p>Спасибо за покупку!</p>
|
| 1790 |
+
</div>
|
| 1791 |
+
</div>
|
| 1792 |
+
</body>
|
| 1793 |
+
</html>
|
| 1794 |
+
"""
|
| 1795 |
|
| 1796 |
if __name__ == '__main__':
|
| 1797 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|