Kgshop commited on
Commit
b2ea4af
·
verified ·
1 Parent(s): 8ba30ad

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +192 -187
app.py CHANGED
@@ -214,7 +214,12 @@ def generate_receipt_html(transaction):
214
  table td {{ padding: 8px; border-bottom: 1px solid #eee; }}
215
  table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
216
  .footer-info {{ font-size: 14px; margin-top: 20px; }}
217
- .print-button {{ padding: 10px 20px; font-size: 16px; cursor: pointer; border: 1px solid #ccc; background-color: #f0f0f0; border-radius: 5px; }}
 
 
 
 
 
218
  @media screen and (max-width: 600px) {{
219
  body {{ padding: 0; }}
220
  .invoice-box {{ padding: 15px; box-shadow: none; border: none; }}
@@ -224,15 +229,13 @@ def generate_receipt_html(transaction):
224
  .header p {{ font-size: 12px; }}
225
  table tr.total td {{ font-size: 1em; }}
226
  }}
227
- @media print {{
228
- .no-print {{ display: none !important; }}
229
- .invoice-box {{ box-shadow: none; border: none; margin: 0; max-width: 100%; }}
230
- body {{ background-color: white; padding: 0; }}
231
- }}
232
  </style>
233
  </head>
234
  <body>
235
  <div class="invoice-box">
 
 
 
236
  <div class="header">
237
  <h1>Товарная накладная № {transaction['id'][:8]}</h1>
238
  <p>от {datetime.fromisoformat(transaction['timestamp']).strftime('%d.%m.%Y %H:%M')}</p>
@@ -254,9 +257,6 @@ def generate_receipt_html(transaction):
254
  <p>Кассир: {transaction['user_name']}</p>
255
  </div>
256
  </div>
257
- <div class="no-print" style="text-align: center; margin-top: 20px;">
258
- <button onclick="window.print()" class="print-button">Печать</button>
259
- </div>
260
  </body>
261
  </html>
262
  """
@@ -303,7 +303,6 @@ def inventory_management():
303
  try:
304
  name = request.form.get('name', '').strip()
305
  barcode = request.form.get('barcode', '').strip()
306
- items_per_pack = int(request.form.get('items_per_pack', 1))
307
 
308
  if not name or not barcode:
309
  flash("Название и штрих-код - обязательные поля.", "danger")
@@ -320,6 +319,7 @@ def inventory_management():
320
  variant_cost_prices = request.form.getlist('variant_cost_price[]')
321
  variant_stocks = request.form.getlist('variant_stock[]')
322
  variant_image_urls = request.form.getlist('variant_image_url[]')
 
323
 
324
  for i in range(len(variant_names)):
325
  v_name = variant_names[i].strip()
@@ -331,7 +331,8 @@ def inventory_management():
331
  'price': str(to_decimal(variant_prices[i])),
332
  'cost_price': str(to_decimal(variant_cost_prices[i])),
333
  'stock': int(to_decimal(variant_stocks[i], '0')),
334
- 'image_url': variant_image_urls[i] if i < len(variant_image_urls) else ''
 
335
  })
336
 
337
  if not variants:
@@ -342,7 +343,6 @@ def inventory_management():
342
  'id': uuid.uuid4().hex,
343
  'name': name,
344
  'barcode': barcode,
345
- 'items_per_pack': items_per_pack,
346
  'variants': variants,
347
  'timestamp_added': get_current_time().isoformat(),
348
  'timestamp_updated': get_current_time().isoformat()
@@ -395,7 +395,6 @@ def edit_product(product_id):
395
  try:
396
  name = request.form.get('name', '').strip()
397
  barcode = request.form.get('barcode', '').strip()
398
- items_per_pack = int(request.form.get('items_per_pack', 1))
399
 
400
  if not name or not barcode:
401
  flash("Название и штрих-код обязательны.", "danger")
@@ -408,7 +407,6 @@ def edit_product(product_id):
408
 
409
  inventory[i]['name'] = name
410
  inventory[i]['barcode'] = barcode
411
- inventory[i]['items_per_pack'] = items_per_pack
412
 
413
  new_variants = []
414
  variant_ids = request.form.getlist('variant_id[]')
@@ -417,6 +415,7 @@ def edit_product(product_id):
417
  variant_cost_prices = request.form.getlist('variant_cost_price[]')
418
  variant_stocks = request.form.getlist('variant_stock[]')
419
  variant_image_urls = request.form.getlist('variant_image_url[]')
 
420
 
421
  for j in range(len(variant_ids)):
422
  v_name = variant_names[j].strip()
@@ -428,7 +427,8 @@ def edit_product(product_id):
428
  'price': str(to_decimal(variant_prices[j])),
429
  'cost_price': str(to_decimal(variant_cost_prices[j])),
430
  'stock': int(to_decimal(variant_stocks[j], '0')),
431
- 'image_url': variant_image_urls[j] if j < len(variant_image_urls) else ''
 
432
  })
433
 
434
  inventory[i]['variants'] = new_variants
@@ -601,7 +601,6 @@ def complete_sale():
601
  'name': cart_item.get('productName', 'Товар без штрихкода'),
602
  'barcode': 'CUSTOM',
603
  'quantity': quantity_sold,
604
- 'returned_quantity': 0,
605
  'price_at_sale': str(price_at_sale),
606
  'cost_price_at_sale': '0.00',
607
  'discount_per_item': '0.00',
@@ -642,7 +641,6 @@ def complete_sale():
642
  'name': f"{product['name']} ({variant['option_value']})",
643
  'barcode': product.get('barcode'),
644
  'quantity': quantity_sold,
645
- 'returned_quantity': 0,
646
  'price_at_sale': str(price_at_sale),
647
  'cost_price_at_sale': str(cost_price_at_sale),
648
  'discount_per_item': str(discount_per_item),
@@ -723,7 +721,6 @@ def view_receipt(transaction_id):
723
  transaction = find_item_by_field(transactions, 'id', transaction_id)
724
  if transaction and 'invoice_html' in transaction:
725
  return Response(transaction['invoice_html'], mimetype='text/html')
726
- # Fallback to old key for compatibility
727
  if transaction and 'receipt_html' in transaction:
728
  return Response(transaction['receipt_html'], mimetype='text/html')
729
  abort(404, description="Накладная не найдена")
@@ -880,7 +877,6 @@ def delete_transaction(transaction_id):
880
  current_balance = to_decimal(kassa.get('balance', '0'))
881
  amount_change = to_decimal(transaction_to_delete.get('total_amount'))
882
 
883
- # For sales, amount is positive, so we subtract. For returns, it's negative, so subtracting a negative adds it back.
884
  kassa['balance'] = str(current_balance - amount_change)
885
 
886
  kassa.setdefault('history', []).append({
@@ -897,11 +893,7 @@ def delete_transaction(transaction_id):
897
  if original_id:
898
  for i, t in enumerate(transactions):
899
  if t.get('id') == original_id:
900
- # Logic to revert status and returned quantities would be complex; for now, we just mark original as completed.
901
- # A full reversal would require finding the deleted return and subtracting its item quantities from the original's `returned_quantity`.
902
- # For simplicity, we just allow re-opening it for returns.
903
- if t.get('status') in ['returned', 'partially_returned']:
904
- t['status'] = 'completed' # Or recalculate to 'partially_returned' if other returns exist.
905
  break
906
 
907
  save_json_data('inventory', inventory)
@@ -935,7 +927,6 @@ def reports():
935
  t for t in transactions
936
  if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
937
  ]
938
- filtered_returns = [t for t in filtered_transactions if t.get('type') == 'return']
939
  filtered_expenses = [
940
  e for e in expenses
941
  if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
@@ -945,10 +936,11 @@ def reports():
945
  if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
946
  ]
947
 
948
- total_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions)
949
- total_return_amount = sum(to_decimal(t['total_amount']) for t in filtered_returns) * -1
950
- return_count = len(filtered_returns)
951
 
 
952
  total_cogs = sum(
953
  to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity']))
954
  for t in filtered_transactions for item in t['items']
@@ -965,8 +957,8 @@ def reports():
965
  sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
966
  elif t.get('type') == 'return':
967
  cashier_name = t.get('user_name', 'Неизвестный')
968
- # Returns are not counted as a sale for the cashier in this logic
969
- pass
970
 
971
  cashier_payouts = defaultdict(Decimal)
972
  for t in filtered_transactions:
@@ -984,7 +976,7 @@ def reports():
984
  monthly_salary = to_decimal(user.get('payment_value', '0'))
985
  if monthly_salary > 0:
986
  daily_salary = monthly_salary / Decimal(30)
987
- period_salary = daily_salary * Decimal(num_days) if num_days > 0 else daily_salary
988
  cashier_payouts[user['name']] += period_salary
989
 
990
  total_salary_expenses = sum(cashier_payouts.values())
@@ -992,14 +984,14 @@ def reports():
992
 
993
  stats = {
994
  'total_revenue': total_revenue,
 
 
995
  'total_cogs': total_cogs,
996
  'gross_profit': gross_profit,
997
  'total_expenses': total_expenses,
998
  'total_personal_expenses': total_personal_expenses,
999
  'total_salary_expenses': total_salary_expenses,
1000
  'net_profit': net_profit,
1001
- 'total_return_amount': total_return_amount,
1002
- 'return_count': return_count,
1003
  'sales_by_cashier': sorted(sales_by_cashier.items(), key=lambda item: item[1]['total'], reverse=True),
1004
  'cashier_payouts': sorted(cashier_payouts.items(), key=lambda item: item[1], reverse=True)
1005
  }
@@ -1405,24 +1397,8 @@ def cashier_dashboard(user_id):
1405
  html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {user['name']}").replace('__CONTENT__', CASHIER_DASHBOARD_CONTENT).replace('__SCRIPTS__', '')
1406
  return render_template_string(html, user=user, transactions=user_transactions)
1407
 
1408
- @app.route('/partial_return/<transaction_id>/<cashier_id>')
1409
- def partial_return_page(transaction_id, cashier_id):
1410
- transactions = load_json_data('transactions')
1411
- transaction = find_item_by_field(transactions, 'id', transaction_id)
1412
- if not transaction:
1413
- abort(404, "Транзакция не найдена")
1414
- if transaction.get('status') == 'returned':
1415
- flash("Эта продажа уже была полностью возвращена.", "warning")
1416
- return redirect(url_for('cashier_dashboard', user_id=cashier_id))
1417
-
1418
- html = BASE_TEMPLATE.replace('__TITLE__', "Оформление возврата").replace('__CONTENT__', PARTIAL_RETURN_CONTENT).replace('__SCRIPTS__', '')
1419
- return render_template_string(html, transaction=transaction, cashier_id=cashier_id)
1420
-
1421
- @app.route('/process_partial_return', methods=['POST'])
1422
- def process_partial_return():
1423
- transaction_id = request.form.get('transaction_id')
1424
- cashier_id = request.form.get('cashier_id')
1425
-
1426
  transactions = load_json_data('transactions')
1427
  inventory = load_json_data('inventory')
1428
  kassas = load_json_data('kassas')
@@ -1435,113 +1411,144 @@ def process_partial_return():
1435
 
1436
  if original_transaction_index == -1:
1437
  flash("Оригинальная транзакция не найдена.", "danger")
1438
- return redirect(url_for('cashier_dashboard', user_id=cashier_id))
1439
 
1440
  original_transaction = transactions[original_transaction_index]
1441
-
1442
- return_items = []
1443
- total_return_amount = Decimal('0.00')
1444
- inventory_updates = {}
1445
- items_to_update_in_original = []
1446
 
1447
- for item in original_transaction['items']:
1448
- variant_id = item.get('variant_id') or item.get('product_id')
1449
- try:
1450
- return_qty = int(request.form.get(f'return_qty_{variant_id}', 0))
1451
- except (ValueError, TypeError):
1452
- return_qty = 0
1453
-
1454
- available_to_return = item['quantity'] - item.get('returned_quantity', 0)
1455
- if return_qty < 0 or return_qty > available_to_return:
1456
- flash(f"Неверное количество для возврата товара '{item['name']}'. Доступно: {available_to_return}", "danger")
1457
- return redirect(url_for('partial_return_page', transaction_id=transaction_id, cashier_id=cashier_id))
1458
-
1459
- if return_qty > 0:
1460
- price_per_item = to_decimal(item['total']) / item['quantity']
1461
- return_amount = price_per_item * return_qty
1462
- total_return_amount += return_amount
1463
 
1464
- returned_item = item.copy()
1465
- returned_item['quantity'] = return_qty
1466
- returned_item['total'] = str(return_amount)
1467
- return_items.append(returned_item)
 
 
 
 
 
 
 
1468
 
1469
- items_to_update_in_original.append({'variant_id': variant_id, 'returned_qty': return_qty})
 
1470
 
1471
- if not item.get('is_custom'):
1472
- inventory_updates[variant_id] = {
1473
- 'product_id': item['product_id'],
1474
- 'qty_to_add': return_qty
1475
- }
1476
-
1477
- if not return_items:
1478
- flash("Не выбрано ни одного товара для возврата.", "warning")
1479
- return redirect(url_for('partial_return_page', transaction_id=transaction_id, cashier_id=cashier_id))
1480
 
1481
- now_iso = get_current_time().isoformat()
1482
- return_transaction = {
1483
- 'id': uuid.uuid4().hex,
1484
- 'timestamp': now_iso,
1485
- 'type': 'return',
1486
- 'status': 'completed',
1487
- 'original_transaction_id': transaction_id,
1488
- 'user_id': original_transaction['user_id'],
1489
- 'user_name': original_transaction['user_name'],
1490
- 'kassa_id': original_transaction['kassa_id'],
1491
- 'kassa_name': original_transaction['kassa_name'],
1492
- 'shift_id': original_transaction.get('shift_id'),
1493
- 'items': return_items,
1494
- 'total_amount': str(-total_return_amount),
1495
- 'payment_method': original_transaction['payment_method']
1496
- }
1497
- transactions.append(return_transaction)
1498
-
1499
- total_sold_qty = 0
1500
- total_returned_qty = 0
1501
- for update_info in items_to_update_in_original:
1502
- for item in transactions[original_transaction_index]['items']:
1503
- v_id = item.get('variant_id') or item.get('product_id')
1504
- if v_id == update_info['variant_id']:
1505
- item['returned_quantity'] = item.get('returned_quantity', 0) + update_info['returned_qty']
1506
- break
1507
 
1508
- for item in transactions[original_transaction_index]['items']:
1509
- total_sold_qty += item['quantity']
1510
- total_returned_qty += item.get('returned_quantity', 0)
1511
-
1512
- if total_returned_qty >= total_sold_qty:
1513
- transactions[original_transaction_index]['status'] = 'returned'
1514
- else:
1515
- transactions[original_transaction_index]['status'] = 'partially_returned'
1516
 
1517
- for variant_id, update_info in inventory_updates.items():
1518
- product = find_item_by_field(inventory, 'id', update_info['product_id'])
1519
- if product:
1520
- variant = find_item_by_field(product.get('variants', []), 'id', variant_id)
1521
- if variant:
1522
- variant['stock'] = variant.get('stock', 0) + update_info['qty_to_add']
1523
- product['timestamp_updated'] = now_iso
1524
-
1525
- if original_transaction['payment_method'] == 'cash' and total_return_amount > 0:
1526
- for i, k in enumerate(kassas):
1527
- if k['id'] == original_transaction['kassa_id']:
1528
- current_balance = to_decimal(k.get('balance', '0'))
1529
- kassas[i]['balance'] = str(current_balance - total_return_amount)
1530
- kassas[i].setdefault('history', []).append({
1531
- 'type': 'return', 'amount': str(-total_return_amount), 'timestamp': now_iso,
1532
- 'transaction_id': return_transaction['id']
1533
- })
1534
- break
1535
 
1536
- save_json_data('transactions', transactions)
1537
- save_json_data('inventory', inventory)
1538
- save_json_data('kassas', kassas)
1539
- upload_db_to_hf('transactions')
1540
- upload_db_to_hf('inventory')
1541
- upload_db_to_hf('kassas')
1542
-
1543
- flash("Возврат успешно оформлен.", "success")
1544
- return redirect(url_for('cashier_dashboard', user_id=cashier_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1545
 
1546
  @app.route('/backup', methods=['POST'])
1547
  @admin_required
@@ -1650,7 +1657,7 @@ BASE_TEMPLATE = """
1650
  <li><a class="dropdown-item" href="{{ url_for('product_roi_report') }}">Окупаемость товаров</a></li>
1651
  </ul>
1652
  </li>
1653
- <li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_login', 'cashier_dashboard', 'partial_return_page'] %}active{% endif %}" href="{{ url_for('cashier_login') }}"><i class="fas fa-fw fa-user-circle me-2"></i>Кабинет кассира</a></li>
1654
  <li class="nav-item"><a class="nav-link {% if request.endpoint in ['admin_panel', 'admin_shifts'] %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cogs me-2"></i>Админка</a></li>
1655
  {% if session.admin_logged_in %}
1656
  <li class="nav-item"><a class="nav-link" href="{{ url_for('admin_logout') }}"><i class="fas fa-fw fa-sign-out-alt me-2"></i>Выйти (Админ)</a></li>
@@ -1952,7 +1959,7 @@ document.addEventListener('DOMContentLoaded', () => {
1952
  };
1953
 
1954
  const addToCart = (product, variant) => {
1955
- const itemsPerPack = product.items_per_pack || 1;
1956
  if (cart[variant.id]) {
1957
  cart[variant.id].quantity += itemsPerPack;
1958
  } else {
@@ -1962,7 +1969,8 @@ document.addEventListener('DOMContentLoaded', () => {
1962
  variantName: variant.option_value,
1963
  price: String(variant.price).replace('.',','),
1964
  quantity: itemsPerPack,
1965
- discount: '0'
 
1966
  };
1967
  }
1968
  playBeep();
@@ -2371,7 +2379,6 @@ INVENTORY_CONTENT = """
2371
  <h2 class="accordion-header" id="heading-{{ p.id }}">
2372
  <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ p.id }}">
2373
  <strong>{{ p.name }}</strong>&nbsp;<small class="text-muted"> ({{ p.barcode }})</small>
2374
- {% if p.get('items_per_pack', 1) > 1 %}<span class="badge bg-info ms-2">{{ p.items_per_pack }} шт/пачка</span>{% endif %}
2375
  </button>
2376
  </h2>
2377
  <div id="collapse-{{ p.id }}" class="accordion-collapse collapse" data-bs-parent="#inventoryAccordion">
@@ -2383,7 +2390,7 @@ INVENTORY_CONTENT = """
2383
  </form>
2384
  </div>
2385
  <table class="table table-sm table-bordered">
2386
- <thead><tr><th>Фото</th><th>Вариант</th><th>Цена</th><th>Себест.</th><th>Остаток</th></tr></thead>
2387
  <tbody>
2388
  {% for v in p.variants %}
2389
  <tr>
@@ -2392,9 +2399,10 @@ INVENTORY_CONTENT = """
2392
  <td>{{ format_currency_py(v.price) }} ₸</td>
2393
  <td>{{ format_currency_py(v.cost_price) }} ₸</td>
2394
  <td>{{ v.stock }}</td>
 
2395
  </tr>
2396
  {% else %}
2397
- <tr><td colspan="5" class="text-center text-muted">Нет вариантов</td></tr>
2398
  {% endfor %}
2399
  </tbody>
2400
  </table>
@@ -2415,7 +2423,6 @@ INVENTORY_CONTENT = """
2415
  <div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label>
2416
  <div class="input-group"><input type="text" name="barcode" class="form-control barcode-input" required><button type="button" class="btn btn-outline-secondary scan-modal-btn"><i class="fas fa-barcode"></i></button></div>
2417
  </div>
2418
- <div class="col-md-6 mb-3"><label class="form-label">Штук в пачке</label><input type="number" name="items_per_pack" class="form-control" value="1" min="1"></div>
2419
  </div>
2420
  <div id="modal-scanner-add" class="mb-2" style="display:none;"></div>
2421
  <hr>
@@ -2439,7 +2446,6 @@ INVENTORY_CONTENT = """
2439
  <div class="row">
2440
  <div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" class="form-control" value="{{ p.name }}" required></div>
2441
  <div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label><input type="text" name="barcode" class="form-control" value="{{ p.barcode }}" required></div>
2442
- <div class="col-md-6 mb-3"><label class="form-label">Штук в пачке</label><input type="number" name="items_per_pack" class="form-control" value="{{ p.get('items_per_pack', 1) }}" min="1"></div>
2443
  </div>
2444
  <hr>
2445
  <h6>Варианты товара</h6>
@@ -2447,15 +2453,16 @@ INVENTORY_CONTENT = """
2447
  {% for v in p.variants %}
2448
  <div class="row g-2 mb-2 align-items-center variant-row">
2449
  <input type="hidden" name="variant_id[]" value="{{ v.id }}">
2450
- <div class="col-2">
2451
  <img src="{{ v.image_url if v.image_url else url_for('static', filename='placeholder.png') }}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;">
2452
  <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*">
2453
  <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value="{{ v.image_url }}">
2454
  </div>
2455
- <div class="col-2"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" value="{{ v.option_value }}" required></div>
2456
  <div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" value="{{ v.price|string|replace('.', ',') }}" inputmode="decimal"></div>
2457
  <div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" value="{{ v.cost_price|string|replace('.', ',') }}" inputmode="decimal"></div>
2458
  <div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="{{ v.stock }}"></div>
 
2459
  <div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div>
2460
  </div>
2461
  {% endfor %}
@@ -2601,15 +2608,16 @@ document.addEventListener('DOMContentLoaded', () => {
2601
  div.className = 'row g-2 mb-2 align-items-center variant-row';
2602
  div.innerHTML = `
2603
  <input type="hidden" name="variant_id[]" value="">
2604
- <div class="col-2">
2605
  <img src="${placeholderImg}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;">
2606
  <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*">
2607
  <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value="">
2608
  </div>
2609
- <div class="col-2"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" required></div>
2610
  <div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" inputmode="decimal"></div>
2611
  <div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" inputmode="decimal"></div>
2612
  <div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="0"></div>
 
2613
  <div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div>
2614
  `;
2615
  div.querySelector('.remove-variant-btn').addEventListener('click', () => div.remove());
@@ -2736,9 +2744,9 @@ TRANSACTIONS_CONTENT = """
2736
  <td>{{ t.user_name }}</td><td>{{ t.kassa_name }}</td>
2737
  <td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
2738
  <td>
2739
- {% if t.status == 'completed' %}<span class="badge bg-success">Завершен</span>
2740
- {% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращен</span>
2741
- {% elif t.status == 'partially_returned' %}<span class="badge bg-warning">Частичный возврат</span>
2742
  {% else %}<span class="badge bg-secondary">{{t.status}}</span>
2743
  {% endif %}
2744
  </td>
@@ -2871,7 +2879,7 @@ REPORTS_CONTENT = """
2871
  <div class="card-body">
2872
  <ul class="list-group list-group-flush">
2873
  <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>
2874
- <li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-undo me-2 text-danger"></i>Сумма возвратов ({{ stats.return_count }} шт.)</span> <strong class="text-danger">-{{ format_currency_py(stats.total_return_amount) }} ₸</strong></li>
2875
  <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>
2876
  <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>
2877
  <li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-receipt me-2 text-warning"></i>Расходы (операционные)</span> <strong class="text-warning">-{{ format_currency_py(stats.total_expenses) }} ₸</strong></li>
@@ -3288,20 +3296,20 @@ CASHIER_DASHBOARD_CONTENT = """
3288
  <tbody>
3289
  {% for t in transactions %}
3290
  <tr class="{% if t.type == 'return' %}table-danger{% endif %}">
3291
- <td><small class="text-muted">{{ t.id[:8] }}</small></td>
3292
  <td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
3293
  <td><span class="badge bg-{{'primary' if t.type == 'sale' else 'warning'}}">{{'Продажа' if t.type == 'sale' else 'Возврат'}}</span></td>
3294
  <td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
3295
  <td>
3296
- {% if t.status == 'completed' %}<span class="badge bg-success">Завершен</span>
3297
- {% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращен</span>
3298
- {% elif t.status == 'partially_returned' %}<span class="badge bg-warning">Частичный возврат</span>
3299
  {% else %}<span class="badge bg-secondary">{{t.status}}</span>
3300
  {% endif %}
3301
  </td>
3302
  <td>
3303
  {% if t.type == 'sale' and t.status in ['completed', 'partially_returned'] %}
3304
- <a href="{{ url_for('partial_return_page', transaction_id=t.id, cashier_id=user.id) }}" class="btn btn-sm btn-warning">Возврат</a>
3305
  {% endif %}
3306
  </td>
3307
  </tr>
@@ -3313,57 +3321,54 @@ CASHIER_DASHBOARD_CONTENT = """
3313
  </div>
3314
  """
3315
 
3316
- PARTIAL_RETURN_CONTENT = """
3317
  <div class="card">
3318
  <div class="card-header">
3319
- <h5 class="mb-0">Возврат по накладной {{ transaction.id[:8] }}</h5>
3320
  </div>
3321
  <div class="card-body">
3322
- <form action="{{ url_for('process_partial_return') }}" method="POST">
3323
- <input type="hidden" name="transaction_id" value="{{ transaction.id }}">
3324
  <input type="hidden" name="cashier_id" value="{{ cashier_id }}">
3325
  <div class="table-responsive">
3326
- <table class="table">
3327
  <thead>
3328
  <tr>
3329
  <th>Товар</th>
3330
  <th class="text-center">Цена за шт.</th>
3331
  <th class="text-center">Продано</th>
3332
- <th class="text-center">Возвращено</th>
3333
- <th style="width: 150px;">К возврату</th>
3334
  </tr>
3335
  </thead>
3336
  <tbody>
3337
- {% for item in transaction.items %}
3338
- {% set returned_qty = item.get('returned_quantity', 0) %}
3339
- {% set available_to_return = item.quantity - returned_qty %}
3340
  <tr>
3341
  <td>{{ item.name }}</td>
3342
  <td class="text-center">{{ format_currency_py(item.price_at_sale) }} ₸</td>
3343
  <td class="text-center">{{ item.quantity }}</td>
3344
- <td class="text-center">{{ returned_qty }}</td>
3345
  <td>
3346
- {% if available_to_return > 0 %}
3347
- <input type="number" name="return_qty_{{ item.get('variant_id') or item.get('product_id') }}" class="form-control" value="0" min="0" max="{{ available_to_return }}">
3348
- {% else %}
3349
- <span class="badge bg-success">Все возвращено</span>
3350
- {% endif %}
3351
  </td>
3352
  </tr>
 
 
 
 
3353
  {% endfor %}
3354
  </tbody>
3355
  </table>
3356
  </div>
 
3357
  <div class="mt-3 d-flex justify-content-end">
3358
  <a href="{{ url_for('cashier_dashboard', user_id=cashier_id) }}" class="btn btn-secondary me-2">Отмена</a>
3359
- <button type="submit" class="btn btn-danger" onclick="return confirm('Вы уверены, что хотите оформить возврат на выбранные товары?');">Оформить возврат</button>
3360
  </div>
 
3361
  </form>
3362
  </div>
3363
  </div>
3364
  """
3365
 
3366
-
3367
  if __name__ == '__main__':
3368
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
3369
  backup_thread.start()
 
214
  table td {{ padding: 8px; border-bottom: 1px solid #eee; }}
215
  table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
216
  .footer-info {{ font-size: 14px; margin-top: 20px; }}
217
+ .print-hide {{ display: block; }}
218
+ @media print {{
219
+ body {{ margin: 0; padding: 0; background-color: #fff; }}
220
+ .invoice-box {{ box-shadow: none; border: none; margin: 0; padding: 0; }}
221
+ .print-hide {{ display: none; }}
222
+ }}
223
  @media screen and (max-width: 600px) {{
224
  body {{ padding: 0; }}
225
  .invoice-box {{ padding: 15px; box-shadow: none; border: none; }}
 
229
  .header p {{ font-size: 12px; }}
230
  table tr.total td {{ font-size: 1em; }}
231
  }}
 
 
 
 
 
232
  </style>
233
  </head>
234
  <body>
235
  <div class="invoice-box">
236
+ <div class="print-hide" style="text-align: right; margin-bottom: 20px;">
237
+ <button onclick="window.print()" style="padding: 8px 12px; font-size: 14px; cursor: pointer;">Печать</button>
238
+ </div>
239
  <div class="header">
240
  <h1>Товарная накладная № {transaction['id'][:8]}</h1>
241
  <p>от {datetime.fromisoformat(transaction['timestamp']).strftime('%d.%m.%Y %H:%M')}</p>
 
257
  <p>Кассир: {transaction['user_name']}</p>
258
  </div>
259
  </div>
 
 
 
260
  </body>
261
  </html>
262
  """
 
303
  try:
304
  name = request.form.get('name', '').strip()
305
  barcode = request.form.get('barcode', '').strip()
 
306
 
307
  if not name or not barcode:
308
  flash("Название и штрих-код - обязательные поля.", "danger")
 
319
  variant_cost_prices = request.form.getlist('variant_cost_price[]')
320
  variant_stocks = request.form.getlist('variant_stock[]')
321
  variant_image_urls = request.form.getlist('variant_image_url[]')
322
+ variant_items_per_packs = request.form.getlist('variant_items_per_pack[]')
323
 
324
  for i in range(len(variant_names)):
325
  v_name = variant_names[i].strip()
 
331
  'price': str(to_decimal(variant_prices[i])),
332
  'cost_price': str(to_decimal(variant_cost_prices[i])),
333
  'stock': int(to_decimal(variant_stocks[i], '0')),
334
+ 'image_url': variant_image_urls[i] if i < len(variant_image_urls) else '',
335
+ 'items_per_pack': int(variant_items_per_packs[i] if i < len(variant_items_per_packs) and variant_items_per_packs[i] else 1)
336
  })
337
 
338
  if not variants:
 
343
  'id': uuid.uuid4().hex,
344
  'name': name,
345
  'barcode': barcode,
 
346
  'variants': variants,
347
  'timestamp_added': get_current_time().isoformat(),
348
  'timestamp_updated': get_current_time().isoformat()
 
395
  try:
396
  name = request.form.get('name', '').strip()
397
  barcode = request.form.get('barcode', '').strip()
 
398
 
399
  if not name or not barcode:
400
  flash("Название и штрих-код обязательны.", "danger")
 
407
 
408
  inventory[i]['name'] = name
409
  inventory[i]['barcode'] = barcode
 
410
 
411
  new_variants = []
412
  variant_ids = request.form.getlist('variant_id[]')
 
415
  variant_cost_prices = request.form.getlist('variant_cost_price[]')
416
  variant_stocks = request.form.getlist('variant_stock[]')
417
  variant_image_urls = request.form.getlist('variant_image_url[]')
418
+ variant_items_per_packs = request.form.getlist('variant_items_per_pack[]')
419
 
420
  for j in range(len(variant_ids)):
421
  v_name = variant_names[j].strip()
 
427
  'price': str(to_decimal(variant_prices[j])),
428
  'cost_price': str(to_decimal(variant_cost_prices[j])),
429
  'stock': int(to_decimal(variant_stocks[j], '0')),
430
+ 'image_url': variant_image_urls[j] if j < len(variant_image_urls) else '',
431
+ 'items_per_pack': int(variant_items_per_packs[j] if j < len(variant_items_per_packs) and variant_items_per_packs[j] else 1)
432
  })
433
 
434
  inventory[i]['variants'] = new_variants
 
601
  'name': cart_item.get('productName', 'Товар без штрихкода'),
602
  'barcode': 'CUSTOM',
603
  'quantity': quantity_sold,
 
604
  'price_at_sale': str(price_at_sale),
605
  'cost_price_at_sale': '0.00',
606
  'discount_per_item': '0.00',
 
641
  'name': f"{product['name']} ({variant['option_value']})",
642
  'barcode': product.get('barcode'),
643
  'quantity': quantity_sold,
 
644
  'price_at_sale': str(price_at_sale),
645
  'cost_price_at_sale': str(cost_price_at_sale),
646
  'discount_per_item': str(discount_per_item),
 
721
  transaction = find_item_by_field(transactions, 'id', transaction_id)
722
  if transaction and 'invoice_html' in transaction:
723
  return Response(transaction['invoice_html'], mimetype='text/html')
 
724
  if transaction and 'receipt_html' in transaction:
725
  return Response(transaction['receipt_html'], mimetype='text/html')
726
  abort(404, description="Накладная не найдена")
 
877
  current_balance = to_decimal(kassa.get('balance', '0'))
878
  amount_change = to_decimal(transaction_to_delete.get('total_amount'))
879
 
 
880
  kassa['balance'] = str(current_balance - amount_change)
881
 
882
  kassa.setdefault('history', []).append({
 
893
  if original_id:
894
  for i, t in enumerate(transactions):
895
  if t.get('id') == original_id:
896
+ transactions[i]['status'] = 'completed'
 
 
 
 
897
  break
898
 
899
  save_json_data('inventory', inventory)
 
927
  t for t in transactions
928
  if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
929
  ]
 
930
  filtered_expenses = [
931
  e for e in expenses
932
  if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
 
936
  if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
937
  ]
938
 
939
+ return_transactions = [t for t in filtered_transactions if t.get('type') == 'return']
940
+ total_returns_amount = sum(to_decimal(t['total_amount']) for t in return_transactions)
941
+ total_returns_count = len(return_transactions)
942
 
943
+ total_revenue = sum(to_decimal(t['total_amount']) for t in filtered_transactions)
944
  total_cogs = sum(
945
  to_decimal(item.get('cost_price_at_sale', '0')) * to_decimal(str(item['quantity']))
946
  for t in filtered_transactions for item in t['items']
 
957
  sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
958
  elif t.get('type') == 'return':
959
  cashier_name = t.get('user_name', 'Неизвестный')
960
+ sales_by_cashier[cashier_name]['count'] -= 1
961
+ sales_by_cashier[cashier_name]['total'] += to_decimal(t['total_amount'])
962
 
963
  cashier_payouts = defaultdict(Decimal)
964
  for t in filtered_transactions:
 
976
  monthly_salary = to_decimal(user.get('payment_value', '0'))
977
  if monthly_salary > 0:
978
  daily_salary = monthly_salary / Decimal(30)
979
+ period_salary = daily_salary * Decimal(num_days)
980
  cashier_payouts[user['name']] += period_salary
981
 
982
  total_salary_expenses = sum(cashier_payouts.values())
 
984
 
985
  stats = {
986
  'total_revenue': total_revenue,
987
+ 'total_returns_amount': abs(total_returns_amount),
988
+ 'total_returns_count': total_returns_count,
989
  'total_cogs': total_cogs,
990
  'gross_profit': gross_profit,
991
  'total_expenses': total_expenses,
992
  'total_personal_expenses': total_personal_expenses,
993
  'total_salary_expenses': total_salary_expenses,
994
  'net_profit': net_profit,
 
 
995
  'sales_by_cashier': sorted(sales_by_cashier.items(), key=lambda item: item[1]['total'], reverse=True),
996
  'cashier_payouts': sorted(cashier_payouts.items(), key=lambda item: item[1], reverse=True)
997
  }
 
1397
  html = BASE_TEMPLATE.replace('__TITLE__', f"Продажи кассира: {user['name']}").replace('__CONTENT__', CASHIER_DASHBOARD_CONTENT).replace('__SCRIPTS__', '')
1398
  return render_template_string(html, user=user, transactions=user_transactions)
1399
 
1400
+ @app.route('/return_transaction/<transaction_id>', methods=['GET', 'POST'])
1401
+ def return_transaction(transaction_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1402
  transactions = load_json_data('transactions')
1403
  inventory = load_json_data('inventory')
1404
  kassas = load_json_data('kassas')
 
1411
 
1412
  if original_transaction_index == -1:
1413
  flash("Оригинальная транзакция не найдена.", "danger")
1414
+ return redirect(url_for('cashier_login'))
1415
 
1416
  original_transaction = transactions[original_transaction_index]
 
 
 
 
 
1417
 
1418
+ if request.method == 'GET':
1419
+ cashier_id = request.args.get('cashier_id')
1420
+ if not cashier_id:
1421
+ flash("Не указан ID кассира.", "danger")
1422
+ return redirect(url_for('cashier_login'))
 
 
 
 
 
 
 
 
 
 
 
1423
 
1424
+ returnable_items = []
1425
+ already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
1426
+
1427
+ for item in original_transaction['items']:
1428
+ variant_id = item.get('variant_id')
1429
+ returned_qty = already_returned.get(variant_id, 0)
1430
+ max_returnable = item['quantity'] - returned_qty
1431
+ if max_returnable > 0:
1432
+ item_copy = item.copy()
1433
+ item_copy['max_returnable'] = max_returnable
1434
+ returnable_items.append(item_copy)
1435
 
1436
+ html = BASE_TEMPLATE.replace('__TITLE__', f"Возврат по накладной {transaction_id[:8]}").replace('__CONTENT__', RETURN_PAGE_CONTENT).replace('__SCRIPTS__', '')
1437
+ return render_template_string(html, transaction=original_transaction, items=returnable_items, cashier_id=cashier_id)
1438
 
1439
+ if request.method == 'POST':
1440
+ cashier_id = request.form.get('cashier_id')
1441
+ if not cashier_id:
1442
+ flash("Не удалось определить кассира.", "danger")
1443
+ return redirect(url_for('cashier_login'))
 
 
 
 
1444
 
1445
+ return_items = []
1446
+ total_return_amount = Decimal('0.00')
1447
+ inventory_updates = {}
1448
+ items_to_process = defaultdict(int)
1449
+
1450
+ for key, value in request.form.items():
1451
+ if key.startswith('return_qty_'):
1452
+ variant_id = key.replace('return_qty_', '')
1453
+ try:
1454
+ qty = int(value)
1455
+ if qty > 0:
1456
+ items_to_process[variant_id] = qty
1457
+ except (ValueError, TypeError):
1458
+ continue
1459
+
1460
+ if not items_to_process:
1461
+ flash("Не выбрано ни одного товара для возврата.", "warning")
1462
+ return redirect(url_for('return_transaction', transaction_id=transaction_id, cashier_id=cashier_id))
 
 
 
 
 
 
 
 
1463
 
1464
+ already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
1465
+ total_items_in_sale = 0
1466
+ total_items_returned_before = sum(already_returned.values())
 
 
 
 
 
1467
 
1468
+ for item in original_transaction['items']:
1469
+ variant_id = item.get('variant_id')
1470
+ total_items_in_sale += item['quantity']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1471
 
1472
+ if variant_id in items_to_process:
1473
+ qty_to_return = items_to_process[variant_id]
1474
+
1475
+ returned_so_far = already_returned.get(variant_id, 0)
1476
+ if qty_to_return > (item['quantity'] - returned_so_far):
1477
+ flash(f"Нельзя вернуть {qty_to_return} шт. товара '{item['name']}', т.к. доступно к возврату {item['quantity'] - returned_so_far}.", "danger")
1478
+ return redirect(url_for('return_transaction', transaction_id=transaction_id, cashier_id=cashier_id))
1479
+
1480
+ price = to_decimal(item['price_at_sale'])
1481
+ discount = to_decimal(item.get('discount_per_item', '0'))
1482
+ item_total = (price - discount) * qty_to_return
1483
+ total_return_amount += item_total
1484
+
1485
+ return_items.append({**item, 'quantity': qty_to_return, 'total': str(item_total)})
1486
+
1487
+ if not item.get('is_custom'):
1488
+ product = find_item_by_field(inventory, 'id', item['product_id'])
1489
+ if product:
1490
+ variant = find_item_by_field(product.get('variants', []), 'id', variant_id)
1491
+ if variant:
1492
+ inventory_updates[variant_id] = {'product_id': item['product_id'], 'stock_change': qty_to_return}
1493
+
1494
+ now_iso = get_current_time().isoformat()
1495
+ return_transaction_id = uuid.uuid4().hex
1496
+ return_transaction = {
1497
+ 'id': return_transaction_id, 'timestamp': now_iso, 'type': 'return', 'status': 'completed',
1498
+ 'original_transaction_id': transaction_id,
1499
+ 'user_id': original_transaction['user_id'], 'user_name': original_transaction['user_name'],
1500
+ 'kassa_id': original_transaction['kassa_id'], 'kassa_name': original_transaction['kassa_name'],
1501
+ 'shift_id': original_transaction.get('shift_id'),
1502
+ 'items': return_items,
1503
+ 'total_amount': str(-total_return_amount),
1504
+ 'payment_method': original_transaction['payment_method']
1505
+ }
1506
+ transactions.append(return_transaction)
1507
+
1508
+ return_info = original_transaction.setdefault('return_info', {'returned_items': {}, 'return_transaction_ids': []})
1509
+ return_info['return_transaction_ids'].append(return_transaction_id)
1510
+ total_items_returned_now = 0
1511
+ for variant_id, qty in items_to_process.items():
1512
+ return_info['returned_items'][variant_id] = return_info['returned_items'].get(variant_id, 0) + qty
1513
+ total_items_returned_now += qty
1514
+
1515
+ if (total_items_returned_before + total_items_returned_now) >= total_items_in_sale:
1516
+ original_transaction['status'] = 'returned'
1517
+ else:
1518
+ original_transaction['status'] = 'partially_returned'
1519
+
1520
+ transactions[original_transaction_index] = original_transaction
1521
+
1522
+ for variant_id, update_info in inventory_updates.items():
1523
+ for p_idx, p in enumerate(inventory):
1524
+ if p.get('id') == update_info['product_id']:
1525
+ for v_idx, v in enumerate(p.get('variants', [])):
1526
+ if v.get('id') == variant_id:
1527
+ inventory[p_idx]['variants'][v_idx]['stock'] += update_info['stock_change']
1528
+ inventory[p_idx]['timestamp_updated'] = now_iso
1529
+ break
1530
+ break
1531
+
1532
+ if original_transaction['payment_method'] == 'cash' and total_return_amount > 0:
1533
+ for k_idx, k in enumerate(kassas):
1534
+ if k.get('id') == original_transaction['kassa_id']:
1535
+ current_balance = to_decimal(k.get('balance', '0'))
1536
+ kassas[k_idx]['balance'] = str(current_balance - total_return_amount)
1537
+ kassas[k_idx].setdefault('history', []).append({
1538
+ 'type': 'return', 'amount': str(-total_return_amount), 'timestamp': now_iso,
1539
+ 'transaction_id': return_transaction_id
1540
+ })
1541
+ break
1542
+
1543
+ save_json_data('transactions', transactions)
1544
+ save_json_data('inventory', inventory)
1545
+ save_json_data('kassas', kassas)
1546
+ upload_db_to_hf('transactions')
1547
+ upload_db_to_hf('inventory')
1548
+ upload_db_to_hf('kassas')
1549
+
1550
+ flash("Возврат успешно оформлен.", "success")
1551
+ return redirect(url_for('cashier_dashboard', user_id=cashier_id))
1552
 
1553
  @app.route('/backup', methods=['POST'])
1554
  @admin_required
 
1657
  <li><a class="dropdown-item" href="{{ url_for('product_roi_report') }}">Окупаемость товаров</a></li>
1658
  </ul>
1659
  </li>
1660
+ <li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_login', 'cashier_dashboard'] %}active{% endif %}" href="{{ url_for('cashier_login') }}"><i class="fas fa-fw fa-user-circle me-2"></i>Кабинет кассира</a></li>
1661
  <li class="nav-item"><a class="nav-link {% if request.endpoint in ['admin_panel', 'admin_shifts'] %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cogs me-2"></i>Админка</a></li>
1662
  {% if session.admin_logged_in %}
1663
  <li class="nav-item"><a class="nav-link" href="{{ url_for('admin_logout') }}"><i class="fas fa-fw fa-sign-out-alt me-2"></i>Выйти (Админ)</a></li>
 
1959
  };
1960
 
1961
  const addToCart = (product, variant) => {
1962
+ const itemsPerPack = variant.items_per_pack || 1;
1963
  if (cart[variant.id]) {
1964
  cart[variant.id].quantity += itemsPerPack;
1965
  } else {
 
1969
  variantName: variant.option_value,
1970
  price: String(variant.price).replace('.',','),
1971
  quantity: itemsPerPack,
1972
+ discount: '0',
1973
+ items_per_pack: itemsPerPack
1974
  };
1975
  }
1976
  playBeep();
 
2379
  <h2 class="accordion-header" id="heading-{{ p.id }}">
2380
  <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ p.id }}">
2381
  <strong>{{ p.name }}</strong>&nbsp;<small class="text-muted"> ({{ p.barcode }})</small>
 
2382
  </button>
2383
  </h2>
2384
  <div id="collapse-{{ p.id }}" class="accordion-collapse collapse" data-bs-parent="#inventoryAccordion">
 
2390
  </form>
2391
  </div>
2392
  <table class="table table-sm table-bordered">
2393
+ <thead><tr><th>Фото</th><th>Вариант</th><th>Цена</th><th>Себест.</th><th>Остаток</th><th class="text-center">В пачке</th></tr></thead>
2394
  <tbody>
2395
  {% for v in p.variants %}
2396
  <tr>
 
2399
  <td>{{ format_currency_py(v.price) }} ₸</td>
2400
  <td>{{ format_currency_py(v.cost_price) }} ₸</td>
2401
  <td>{{ v.stock }}</td>
2402
+ <td class="text-center">{{ v.get('items_per_pack', 1) }}</td>
2403
  </tr>
2404
  {% else %}
2405
+ <tr><td colspan="6" class="text-center text-muted">Нет вариантов</td></tr>
2406
  {% endfor %}
2407
  </tbody>
2408
  </table>
 
2423
  <div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label>
2424
  <div class="input-group"><input type="text" name="barcode" class="form-control barcode-input" required><button type="button" class="btn btn-outline-secondary scan-modal-btn"><i class="fas fa-barcode"></i></button></div>
2425
  </div>
 
2426
  </div>
2427
  <div id="modal-scanner-add" class="mb-2" style="display:none;"></div>
2428
  <hr>
 
2446
  <div class="row">
2447
  <div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" class="form-control" value="{{ p.name }}" required></div>
2448
  <div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label><input type="text" name="barcode" class="form-control" value="{{ p.barcode }}" required></div>
 
2449
  </div>
2450
  <hr>
2451
  <h6>Варианты товара</h6>
 
2453
  {% for v in p.variants %}
2454
  <div class="row g-2 mb-2 align-items-center variant-row">
2455
  <input type="hidden" name="variant_id[]" value="{{ v.id }}">
2456
+ <div class="col-md-2">
2457
  <img src="{{ v.image_url if v.image_url else url_for('static', filename='placeholder.png') }}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;">
2458
  <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*">
2459
  <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value="{{ v.image_url }}">
2460
  </div>
2461
+ <div class="col-md-2"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" value="{{ v.option_value }}" required></div>
2462
  <div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" value="{{ v.price|string|replace('.', ',') }}" inputmode="decimal"></div>
2463
  <div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" value="{{ v.cost_price|string|replace('.', ',') }}" inputmode="decimal"></div>
2464
  <div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="{{ v.stock }}"></div>
2465
+ <div class="col"><input type="number" name="variant_items_per_pack[]" class="form-control" placeholder="В пачке" value="{{ v.get('items_per_pack', 1) }}" min="1"></div>
2466
  <div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div>
2467
  </div>
2468
  {% endfor %}
 
2608
  div.className = 'row g-2 mb-2 align-items-center variant-row';
2609
  div.innerHTML = `
2610
  <input type="hidden" name="variant_id[]" value="">
2611
+ <div class="col-md-2">
2612
  <img src="${placeholderImg}" class="img-thumbnail variant-preview mb-1" style="width: 50px; height: 50px; object-fit: cover;">
2613
  <input type="file" class="form-control form-control-sm variant-image-upload" accept="image/*">
2614
  <input type="hidden" class="variant-image-url-input" name="variant_image_url[]" value="">
2615
  </div>
2616
+ <div class="col-md-2"><input type="text" name="variant_name[]" class="form-control" placeholder="Название варианта" required></div>
2617
  <div class="col"><input type="text" name="variant_price[]" class="form-control" placeholder="Цена" inputmode="decimal"></div>
2618
  <div class="col"><input type="text" name="variant_cost_price[]" class="form-control" placeholder="Себестоимость" inputmode="decimal"></div>
2619
  <div class="col"><input type="number" name="variant_stock[]" class="form-control" placeholder="Остаток" value="0"></div>
2620
+ <div class="col"><input type="number" name="variant_items_per_pack[]" class="form-control" placeholder="В пачке" value="1" min="1"></div>
2621
  <div class="col-auto"><button type="button" class="btn btn-sm btn-danger remove-variant-btn"><i class="fas fa-times"></i></button></div>
2622
  `;
2623
  div.querySelector('.remove-variant-btn').addEventListener('click', () => div.remove());
 
2744
  <td>{{ t.user_name }}</td><td>{{ t.kassa_name }}</td>
2745
  <td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
2746
  <td>
2747
+ {% if t.status == 'completed' %}<span class="badge bg-success">Завершено</span>
2748
+ {% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращено</span>
2749
+ {% elif t.status == 'partially_returned' %}<span class="badge bg-warning text-dark">Частичный возврат</span>
2750
  {% else %}<span class="badge bg-secondary">{{t.status}}</span>
2751
  {% endif %}
2752
  </td>
 
2879
  <div class="card-body">
2880
  <ul class="list-group list-group-flush">
2881
  <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>
2882
+ <li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-undo me-2 text-danger"></i>Возвраты ({{ stats.total_returns_count }} шт.)</span> <strong class="text-danger">-{{ format_currency_py(stats.total_returns_amount) }} ₸</strong></li>
2883
  <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>
2884
  <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>
2885
  <li class="list-group-item d-flex justify-content-between"><span><i class="fas fa-receipt me-2 text-warning"></i>Расходы (операционные)</span> <strong class="text-warning">-{{ format_currency_py(stats.total_expenses) }} ₸</strong></li>
 
3296
  <tbody>
3297
  {% for t in transactions %}
3298
  <tr class="{% if t.type == 'return' %}table-danger{% endif %}">
3299
+ <td><a href="{{ url_for('view_receipt', transaction_id=t.id) if t.invoice_html or t.receipt_html else '#' }}" target="_blank"><small class="text-muted">{{ t.id[:8] }}</small></a></td>
3300
  <td>{{ t.timestamp[:16]|replace('T', ' ') }}</td>
3301
  <td><span class="badge bg-{{'primary' if t.type == 'sale' else 'warning'}}">{{'Продажа' if t.type == 'sale' else 'Возврат'}}</span></td>
3302
  <td class="fw-bold">{{ format_currency_py(t.total_amount) }} ₸</td>
3303
  <td>
3304
+ {% if t.status == 'completed' %}<span class="badge bg-success">Завершено</span>
3305
+ {% elif t.status == 'returned' %}<span class="badge bg-danger">Возвращено</span>
3306
+ {% elif t.status == 'partially_returned' %}<span class="badge bg-warning text-dark">Частичный возврат</span>
3307
  {% else %}<span class="badge bg-secondary">{{t.status}}</span>
3308
  {% endif %}
3309
  </td>
3310
  <td>
3311
  {% if t.type == 'sale' and t.status in ['completed', 'partially_returned'] %}
3312
+ <a href="{{ url_for('return_transaction', transaction_id=t.id, cashier_id=user.id) }}" class="btn btn-sm btn-warning">Возврат</a>
3313
  {% endif %}
3314
  </td>
3315
  </tr>
 
3321
  </div>
3322
  """
3323
 
3324
+ RETURN_PAGE_CONTENT = """
3325
  <div class="card">
3326
  <div class="card-header">
3327
+ <h5 class="mb-0">Оформление возврата по накладной <a href="{{ url_for('view_receipt', transaction_id=transaction.id) }}" target="_blank">{{ transaction.id[:8] }}</a></h5>
3328
  </div>
3329
  <div class="card-body">
3330
+ <form method="POST" action="{{ url_for('return_transaction', transaction_id=transaction.id) }}">
 
3331
  <input type="hidden" name="cashier_id" value="{{ cashier_id }}">
3332
  <div class="table-responsive">
3333
+ <table class="table table-bordered">
3334
  <thead>
3335
  <tr>
3336
  <th>Товар</th>
3337
  <th class="text-center">Цена за шт.</th>
3338
  <th class="text-center">Продано</th>
3339
+ <th class="text-center">Вернуть (шт.)</th>
 
3340
  </tr>
3341
  </thead>
3342
  <tbody>
3343
+ {% for item in items %}
 
 
3344
  <tr>
3345
  <td>{{ item.name }}</td>
3346
  <td class="text-center">{{ format_currency_py(item.price_at_sale) }} ₸</td>
3347
  <td class="text-center">{{ item.quantity }}</td>
 
3348
  <td>
3349
+ <input type="number" name="return_qty_{{ item.variant_id }}" class="form-control" value="0" min="0" max="{{ item.max_returnable }}">
3350
+ <small class="form-text text-muted">Доступно: {{ item.max_returnable }} шт.</small>
 
 
 
3351
  </td>
3352
  </tr>
3353
+ {% else %}
3354
+ <tr>
3355
+ <td colspan="4" class="text-center">Нет товаров, доступных для возврата.</td>
3356
+ </tr>
3357
  {% endfor %}
3358
  </tbody>
3359
  </table>
3360
  </div>
3361
+ {% if items %}
3362
  <div class="mt-3 d-flex justify-content-end">
3363
  <a href="{{ url_for('cashier_dashboard', user_id=cashier_id) }}" class="btn btn-secondary me-2">Отмена</a>
3364
+ <button type="submit" class="btn btn-warning">Оформить возврат</button>
3365
  </div>
3366
+ {% endif %}
3367
  </form>
3368
  </div>
3369
  </div>
3370
  """
3371
 
 
3372
  if __name__ == '__main__':
3373
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
3374
  backup_thread.start()