Update app.py
Browse files
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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 949 |
-
|
| 950 |
-
|
| 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 |
-
|
| 969 |
-
|
| 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)
|
| 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('/
|
| 1409 |
-
def
|
| 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('
|
| 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 |
-
|
| 1448 |
-
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
|
| 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 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1468 |
|
| 1469 |
-
|
|
|
|
| 1470 |
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 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 |
-
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
|
| 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 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 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 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 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 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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 =
|
| 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> <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="
|
| 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>
|
| 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 |
-
|
| 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('
|
| 3305 |
{% endif %}
|
| 3306 |
</td>
|
| 3307 |
</tr>
|
|
@@ -3313,57 +3321,54 @@ CASHIER_DASHBOARD_CONTENT = """
|
|
| 3313 |
</div>
|
| 3314 |
"""
|
| 3315 |
|
| 3316 |
-
|
| 3317 |
<div class="card">
|
| 3318 |
<div class="card-header">
|
| 3319 |
-
<h5 class="mb-0">
|
| 3320 |
</div>
|
| 3321 |
<div class="card-body">
|
| 3322 |
-
<form action="{{ url_for('
|
| 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">В
|
| 3333 |
-
<th style="width: 150px;">К возврату</th>
|
| 3334 |
</tr>
|
| 3335 |
</thead>
|
| 3336 |
<tbody>
|
| 3337 |
-
{% for item in
|
| 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 |
-
{
|
| 3347 |
-
<
|
| 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-
|
| 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> <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()
|