Kgshop commited on
Commit
dbf95d5
·
verified ·
1 Parent(s): 2e09903

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +615 -93
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__', "История продаж").replace('__CONTENT__', TRANSACTIONS_CONTENT).replace('__SCRIPTS__', TRANSACTIONS_SCRIPTS)
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', (today).strftime('%Y-%m-%d'))
386
 
387
  start_date = datetime.strptime(start_date_str, '%Y-%m-%d').replace(tzinfo=BISHKEK_TZ)
388
- end_date = (datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)).replace(tzinfo=BISHKEK_TZ)
 
 
389
 
390
  transactions = load_json_data('transactions')
391
  expenses = load_json_data('expenses')
 
 
 
 
 
392
 
393
- filtered_transactions = [
394
- t for t in transactions
395
- if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
396
- ]
397
- filtered_expenses = [
398
- e for e in expenses
399
- if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
400
- ]
401
 
402
- total_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions)
403
- total_cogs = sum(
404
- to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity']))
405
- for t in filtered_transactions for item in t['items']
406
- )
407
- gross_profit = total_revenue - total_cogs
408
- total_expenses = sum(to_decimal(e['amount']) for e in filtered_expenses)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- new_user = {'id': uuid.uuid4().hex, 'name': name, 'pin': pin}
450
- users.append(new_user)
451
- flash(f"Кассир '{name}' добавлен.", "success")
 
 
 
 
 
 
 
 
 
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 == 'transaction_history' %}active{% endif %}" href="{{ url_for('transaction_history') }}"><i class="fas fa-fw fa-history me-2"></i>Продажи</a></li>
 
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
- alert('Продажа завершена!');
 
 
 
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
- if (currentScanner) {
 
1020
  currentScanner.stop();
1021
  currentScanner = null;
1022
- scannerContainer.style.display = 'none';
 
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
- currentScanner = null;
1035
- scannerContainer.style.display = 'none';
 
 
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><span class="badge bg-{{'success' if t.payment_method == 'cash' else 'info'}}">{{'Нал.' if t.payment_method == 'cash' else 'Карта'}}</span></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-chart-bar me-2 text-primary"></i>Выручка (сумма продаж)</span> <strong>{{ format_currency_py(stats.total_revenue) }} с</strong></li>
1104
- <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>
 
 
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>Расходы</span> <strong class="text-danger">-{{ format_currency_py(stats.total_expenses) }} с</strong></li>
 
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">Расходы за период</h5></div>
1113
  <div class="card-body p-0">
1114
- <div class="table-responsive">
1115
- <table class="table table-sm mb-0">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-md-6 mb-4">
1164
  <div class="card h-100">
1165
- <div class="card-header"><h5 class="mb-0">Кассиры</h5></div>
1166
  <div class="card-body">
1167
- <form action="{{ url_for('manage_user') }}" method="POST" class="mb-3">
1168
- <input type="hidden" name="action" value="add">
1169
- <div class="input-group">
1170
- <input type="text" name="name" class="form-control" placeholder="Имя кассира" required>
1171
- <input type="password" name="pin" class="form-control" placeholder="ПИН-код" required>
1172
- <button type="submit" class="btn btn-primary">Добавить</button>
1173
- </div>
1174
- </form>
1175
- <ul class="list-group">
1176
- {% for u in users %}
1177
- <li class="list-group-item d-flex justify-content-between align-items-center">
1178
- {{ u.name }}
1179
- <form action="{{ url_for('manage_user') }}" method="POST" 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-danger"><i class="fas fa-trash"></i></button></form>
1180
- </li>
1181
- {% endfor %}
1182
- </ul>
 
 
 
 
 
 
 
 
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
- <table class="table table-sm table-striped">
1239
- <tbody>
1240
- {% for e in expenses %}
1241
- <tr>
1242
- <td><small>{{ e.timestamp[:16]|replace('T', ' ') }}</small></td>
1243
- <td>{{ e.description }}</td>
1244
- <td class="text-end fw-bold">{{ format_currency_py(e.amount) }} с</td>
1245
- </tr>
1246
- {% else %}
1247
- <tr><td colspan="3" class="text-center">Расходов пока нет</td></tr>
1248
- {% endfor %}
1249
- </tbody>
1250
- </table>
1251
- </div>
1252
  </div>
1253
  </div>
1254
  </div>
1255
- <div class="col-12">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1256
  <div class="card">
1257
- <div class="card-header"><h5 class="mb-0">Резервное копирование</h5></div>
1258
  <div class="card-body">
1259
- <p>Данные периодически сохраняются в облако. Можно сделать это вручную.</p>
1260
- <form method="POST" action="{{ url_for('backup_hf') }}" class="d-inline-block me-2">
1261
- <button type="submit" class="btn btn-outline-primary"><i class="fas fa-cloud-upload-alt me-2"></i>Сохранить в облако</button>
1262
- </form>
1263
- <form method="GET" action="{{ url_for('download_hf') }}" class="d-inline-block" onsubmit="return confirm('ВНИМАНИЕ! Это действие заменит все локальные данные данными из облака. Продолжить?');">
1264
- <button type="submit" class="btn btn-outline-danger"><i class="fas fa-cloud-download-alt me-2"></i>Загрузить из облака</button>
 
 
 
1265
  </form>
1266
  </div>
1267
  </div>
@@ -1269,7 +1651,147 @@ ADMIN_CONTENT = """
1269
  </div>
1270
  """
1271
 
1272
- ADMIN_SCRIPTS = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)