Update app.py
Browse files
app.py
CHANGED
|
@@ -95,7 +95,7 @@ def load_json_data(file_key):
|
|
| 95 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 96 |
return json.load(f)
|
| 97 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 98 |
-
return
|
| 99 |
|
| 100 |
def save_json_data(file_key, data):
|
| 101 |
filepath, lock = DATA_FILES[file_key]
|
|
@@ -162,64 +162,73 @@ def format_currency_py(value):
|
|
| 162 |
return "0"
|
| 163 |
|
| 164 |
def generate_receipt_html(transaction):
|
| 165 |
-
has_discounts = any(to_decimal(item.get('discount_per_item', '0')) > 0 for item in transaction.get('items',
|
| 166 |
delivery_cost = to_decimal(transaction.get('delivery_cost', '0'))
|
| 167 |
note = transaction.get('note', '')
|
| 168 |
|
| 169 |
if has_discounts:
|
| 170 |
table_headers = """
|
| 171 |
-
<th style="text-align: center; width: 5%;">№</th>
|
| 172 |
-
<th style="text-align: left;">Наименование</th>
|
| 173 |
-
<th style="text-align: right;">Кол-во</th>
|
| 174 |
-
<th style="text-align: right;">Цена</th>
|
| 175 |
-
<th style="text-align: right;">Скидка</th>
|
| 176 |
-
<th style="text-align: right;">Сумма</th>
|
| 177 |
"""
|
| 178 |
total_colspan = 5
|
| 179 |
else:
|
| 180 |
table_headers = """
|
| 181 |
-
<th style="text-align: center; width: 5%;">№</th>
|
| 182 |
-
<th style="text-align: left;">Наименование</th>
|
| 183 |
-
<th style="text-align: right;">Кол-во</th>
|
| 184 |
-
<th style="text-align: right;">Цена</th>
|
| 185 |
-
<th style="text-align: right;">Сумма</th>
|
| 186 |
"""
|
| 187 |
total_colspan = 4
|
| 188 |
|
| 189 |
items_html = ""
|
|
|
|
| 190 |
for i, item in enumerate(transaction['items']):
|
| 191 |
-
|
|
|
|
| 192 |
items_html += f"""
|
| 193 |
<tr>
|
| 194 |
-
<td style="text-align: center;">{i + 1}</td>
|
| 195 |
-
<td>{item['name']}</td>
|
| 196 |
-
<td style="text-align: right;">{item['quantity']}</td>
|
| 197 |
-
<td style="text-align: right;">{format_currency_py(item['price_at_sale'])}</td>
|
| 198 |
{discount_cell}
|
| 199 |
-
<td style="text-align: right;">{format_currency_py(item['total'])}</td>
|
| 200 |
</tr>
|
| 201 |
"""
|
| 202 |
|
| 203 |
total_amount_from_db = to_decimal(transaction['total_amount'])
|
| 204 |
|
| 205 |
totals_html = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
if delivery_cost > 0:
|
| 207 |
subtotal = total_amount_from_db - delivery_cost
|
| 208 |
totals_html += f"""
|
| 209 |
<tr>
|
| 210 |
-
<td colspan="{total_colspan}" style="text-align: right;">Подытог:</td>
|
| 211 |
-
<td style="text-align: right;">{format_currency_py(subtotal)} ₸</td>
|
| 212 |
</tr>
|
| 213 |
<tr>
|
| 214 |
-
<td colspan="{total_colspan}" style="text-align: right;">Доставка:</td>
|
| 215 |
-
<td style="text-align: right;">{format_currency_py(delivery_cost)} ₸</td>
|
| 216 |
</tr>
|
| 217 |
"""
|
| 218 |
|
| 219 |
totals_html += f"""
|
| 220 |
<tr class="total">
|
| 221 |
-
<td colspan="{total_colspan}" style="text-align: right;">Итого к оплате:</td>
|
| 222 |
-
<td style="text-align: right;">{format_currency_py(total_amount_from_db)} ₸</td>
|
| 223 |
</tr>
|
| 224 |
"""
|
| 225 |
|
|
@@ -256,8 +265,8 @@ def generate_receipt_html(transaction):
|
|
| 256 |
.header p {{ margin: 2px 0; font-size: 14px; }}
|
| 257 |
.details-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; font-size: 14px; }}
|
| 258 |
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
|
| 259 |
-
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; }}
|
| 260 |
-
table td {{ padding: 8px; border-bottom: 1px solid #eee; }}
|
| 261 |
table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
|
| 262 |
.footer-info {{ font-size: 14px; margin-top: 20px; }}
|
| 263 |
.print-hide {{ display: block; }}
|
|
@@ -360,22 +369,16 @@ def sales_screen():
|
|
| 360 |
transactions = load_json_data('transactions')
|
| 361 |
edit_tx = find_item_by_field(transactions, 'id', edit_tx_id)
|
| 362 |
|
| 363 |
-
active_inventory =
|
| 364 |
for p in inventory:
|
| 365 |
-
if isinstance(p, dict) and any(v.get('stock', 0) > 0 for v in p.get('variants',
|
| 366 |
active_inventory.append(p)
|
| 367 |
|
| 368 |
active_inventory.sort(key=lambda x: x.get('name', '').lower())
|
| 369 |
-
|
| 370 |
-
grouped_inventory = defaultdict(list)
|
| 371 |
-
for p in active_inventory:
|
| 372 |
-
first_letter = p.get('name', '#')[0].upper()
|
| 373 |
-
grouped_inventory[first_letter].append(p)
|
| 374 |
-
|
| 375 |
-
sorted_grouped_inventory = sorted(grouped_inventory.items())
|
| 376 |
|
| 377 |
html = BASE_TEMPLATE.replace('__TITLE__', "Касса").replace('__CONTENT__', SALES_SCREEN_CONTENT).replace('__SCRIPTS__', SALES_SCREEN_SCRIPTS)
|
| 378 |
-
return render_template_string(html, inventory=active_inventory, kassas=kassas,
|
| 379 |
|
| 380 |
@app.route('/inventory', methods=['GET', 'POST'])
|
| 381 |
def inventory_management():
|
|
@@ -461,7 +464,7 @@ def inventory_management():
|
|
| 461 |
|
| 462 |
for product in inventory_list:
|
| 463 |
if isinstance(product, dict) and 'variants' in product:
|
| 464 |
-
for variant in product.get('variants',
|
| 465 |
stock = variant.get('stock', 0)
|
| 466 |
cost_price = to_decimal(variant.get('cost_price', '0'))
|
| 467 |
price = to_decimal(variant.get('price_regular', variant.get('price', '0')))
|
|
@@ -588,7 +591,7 @@ def edit_product(product_id):
|
|
| 588 |
def delete_product(product_id):
|
| 589 |
inventory = load_json_data('inventory')
|
| 590 |
initial_len = len(inventory)
|
| 591 |
-
inventory =
|
| 592 |
if len(inventory) < initial_len:
|
| 593 |
save_json_data('inventory', inventory)
|
| 594 |
upload_db_to_hf('inventory')
|
|
@@ -620,7 +623,7 @@ def stock_in():
|
|
| 620 |
|
| 621 |
variant_found = False
|
| 622 |
variant_name_for_log = ""
|
| 623 |
-
for i, variant in enumerate(product.get('variants',
|
| 624 |
if variant.get('id') == variant_id:
|
| 625 |
variant_name_for_log = variant.get('option_value', '')
|
| 626 |
|
|
@@ -704,7 +707,7 @@ def get_product_by_barcode(barcode):
|
|
| 704 |
inventory = load_json_data('inventory')
|
| 705 |
product = find_item_by_field(inventory, 'barcode', barcode)
|
| 706 |
if product:
|
| 707 |
-
active_variants =
|
| 708 |
if active_variants:
|
| 709 |
product_copy = product.copy()
|
| 710 |
product_copy['variants'] = active_variants
|
|
@@ -734,11 +737,11 @@ def complete_sale():
|
|
| 734 |
if not original_tx:
|
| 735 |
return jsonify({'success': False, 'message': 'Оригинальная накладная не найдена.'}), 404
|
| 736 |
|
| 737 |
-
for item in original_tx.get('items',
|
| 738 |
if not item.get('is_custom'):
|
| 739 |
for p in inventory:
|
| 740 |
if p.get('id') == item.get('product_id'):
|
| 741 |
-
for v in p.get('variants',
|
| 742 |
if v.get('id') == item.get('variant_id'):
|
| 743 |
v['stock'] = v.get('stock', 0) + item.get('quantity', 0)
|
| 744 |
break
|
|
@@ -750,7 +753,7 @@ def complete_sale():
|
|
| 750 |
current_balance = to_decimal(k.get('balance', '0'))
|
| 751 |
amount = to_decimal(original_tx.get('total_amount', '0'))
|
| 752 |
k['balance'] = str(current_balance - amount)
|
| 753 |
-
k.setdefault('history',
|
| 754 |
'type': 'correction_revert',
|
| 755 |
'amount': str(-amount),
|
| 756 |
'timestamp': get_current_time().isoformat(),
|
|
@@ -774,7 +777,7 @@ def complete_sale():
|
|
| 774 |
if not user or not kassa:
|
| 775 |
return jsonify({'success': False, 'message': 'Кассир или касса были удалены. Требуется повторный вход.', 'logout_required': True}), 401
|
| 776 |
|
| 777 |
-
sale_items =
|
| 778 |
items_total = Decimal('0')
|
| 779 |
inventory_updates = {}
|
| 780 |
|
|
@@ -803,7 +806,7 @@ def complete_sale():
|
|
| 803 |
if not product:
|
| 804 |
return jsonify({'success': False, 'message': f"Товар с ID {cart_item['productId']} не найден."}), 404
|
| 805 |
|
| 806 |
-
variant = find_item_by_field(product.get('variants',
|
| 807 |
if not variant:
|
| 808 |
return jsonify({'success': False, 'message': f"Вариант товара с ID {variant_id} не найден."}), 404
|
| 809 |
|
|
@@ -862,7 +865,7 @@ def complete_sale():
|
|
| 862 |
}
|
| 863 |
|
| 864 |
if edit_tx_id:
|
| 865 |
-
new_transaction['edits'] = original_tx.get('edits', []) +
|
| 866 |
|
| 867 |
new_transaction['invoice_html'] = generate_receipt_html(new_transaction)
|
| 868 |
|
|
@@ -877,7 +880,7 @@ def complete_sale():
|
|
| 877 |
for variant_id, update_info in inventory_updates.items():
|
| 878 |
for p in inventory:
|
| 879 |
if p.get('id') == update_info['product_id']:
|
| 880 |
-
for v in p.get('variants',
|
| 881 |
if v.get('id') == variant_id:
|
| 882 |
v['stock'] = update_info['new_stock']
|
| 883 |
p['timestamp_updated'] = now_iso
|
|
@@ -950,7 +953,7 @@ def transaction_history():
|
|
| 950 |
]
|
| 951 |
|
| 952 |
if selected_kassa_id:
|
| 953 |
-
filtered_transactions =
|
| 954 |
t for t in filtered_transactions
|
| 955 |
if t.get('kassa_id') == selected_kassa_id
|
| 956 |
]
|
|
@@ -960,7 +963,7 @@ def transaction_history():
|
|
| 960 |
total_quantity_sold = 0
|
| 961 |
for t in filtered_transactions:
|
| 962 |
if t.get('type') == 'sale':
|
| 963 |
-
for item in t.get('items',
|
| 964 |
total_quantity_sold += int(item.get('quantity', 0))
|
| 965 |
|
| 966 |
filtered_transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
|
@@ -973,7 +976,7 @@ def transaction_history():
|
|
| 973 |
def edit_transaction(transaction_id):
|
| 974 |
try:
|
| 975 |
data = request.get_json()
|
| 976 |
-
items_update = data.get('items',
|
| 977 |
|
| 978 |
transactions = load_json_data('transactions')
|
| 979 |
kassas = load_json_data('kassas')
|
|
@@ -1030,7 +1033,7 @@ def edit_transaction(transaction_id):
|
|
| 1030 |
for i, k in enumerate(kassas):
|
| 1031 |
if k.get('id') == kassa_id:
|
| 1032 |
k['balance'] = str(to_decimal(k.get('balance', '0')) + amount_diff)
|
| 1033 |
-
k.setdefault('history',
|
| 1034 |
'type': 'correction',
|
| 1035 |
'amount': str(amount_diff),
|
| 1036 |
'timestamp': get_current_time().isoformat(),
|
|
@@ -1061,14 +1064,14 @@ def delete_transaction(transaction_id):
|
|
| 1061 |
flash("Транзакция не найдена.", "danger")
|
| 1062 |
return redirect(url_for('transaction_history'))
|
| 1063 |
|
| 1064 |
-
for item in transaction_to_delete.get('items',
|
| 1065 |
if item.get('is_custom'):
|
| 1066 |
continue
|
| 1067 |
|
| 1068 |
product = find_item_by_field(inventory, 'id', item.get('product_id'))
|
| 1069 |
if not product: continue
|
| 1070 |
|
| 1071 |
-
variant = find_item_by_field(product.get('variants',
|
| 1072 |
if not variant: continue
|
| 1073 |
|
| 1074 |
quantity_change = item.get('quantity', 0)
|
|
@@ -1086,14 +1089,14 @@ def delete_transaction(transaction_id):
|
|
| 1086 |
|
| 1087 |
kassa['balance'] = str(current_balance - amount_change)
|
| 1088 |
|
| 1089 |
-
kassa.setdefault('history',
|
| 1090 |
'type': 'deletion',
|
| 1091 |
'amount': str(-amount_change),
|
| 1092 |
'timestamp': get_current_time().isoformat(),
|
| 1093 |
'description': f"Удаление транзакции {transaction_id[:8]}"
|
| 1094 |
})
|
| 1095 |
|
| 1096 |
-
transactions =
|
| 1097 |
|
| 1098 |
if transaction_to_delete.get('type') == 'return':
|
| 1099 |
original_id = transaction_to_delete.get('original_transaction_id')
|
|
@@ -1130,20 +1133,20 @@ def reports():
|
|
| 1130 |
personal_expenses = load_json_data('personal_expenses')
|
| 1131 |
users = load_json_data('users')
|
| 1132 |
|
| 1133 |
-
filtered_transactions =
|
| 1134 |
t for t in transactions
|
| 1135 |
if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
|
| 1136 |
]
|
| 1137 |
-
filtered_expenses =
|
| 1138 |
e for e in expenses
|
| 1139 |
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
|
| 1140 |
]
|
| 1141 |
-
filtered_personal_expenses =
|
| 1142 |
e for e in personal_expenses
|
| 1143 |
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
|
| 1144 |
]
|
| 1145 |
|
| 1146 |
-
return_transactions =
|
| 1147 |
total_returns_amount = sum(to_decimal(t['total_amount']) for t in return_transactions)
|
| 1148 |
total_returns_count = len(return_transactions)
|
| 1149 |
|
|
@@ -1222,7 +1225,7 @@ def employee_report():
|
|
| 1222 |
end_date = (datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)).replace(tzinfo=ALMATY_TZ)
|
| 1223 |
|
| 1224 |
transactions = load_json_data('transactions')
|
| 1225 |
-
employee_transactions =
|
| 1226 |
totals = {'sales': Decimal(0), 'returns': Decimal(0), 'net': Decimal(0)}
|
| 1227 |
|
| 1228 |
if user_id:
|
|
@@ -1254,17 +1257,17 @@ def item_movement_report():
|
|
| 1254 |
product_id = request.args.get('product_id', '')
|
| 1255 |
variant_id = request.args.get('variant_id', '')
|
| 1256 |
|
| 1257 |
-
movements =
|
| 1258 |
selected_product = None
|
| 1259 |
selected_variant = None
|
| 1260 |
|
| 1261 |
if product_id:
|
| 1262 |
selected_product = find_item_by_field(inventory, 'id', product_id)
|
| 1263 |
if selected_product and variant_id:
|
| 1264 |
-
selected_variant = find_item_by_field(selected_product.get('variants',
|
| 1265 |
|
| 1266 |
for t in transactions:
|
| 1267 |
-
for item in t.get('items',
|
| 1268 |
if item.get('product_id') == product_id and (not variant_id or item.get('variant_id') == variant_id):
|
| 1269 |
movements.append({
|
| 1270 |
'timestamp': t['timestamp'],
|
|
@@ -1302,13 +1305,13 @@ def product_roi_report():
|
|
| 1302 |
product_stats = []
|
| 1303 |
|
| 1304 |
for product in inventory:
|
| 1305 |
-
for variant in product.get('variants',
|
| 1306 |
total_revenue = Decimal('0')
|
| 1307 |
total_cogs = Decimal('0')
|
| 1308 |
total_qty_sold = 0
|
| 1309 |
|
| 1310 |
for t in transactions:
|
| 1311 |
-
if t['type'] in
|
| 1312 |
for item in t['items']:
|
| 1313 |
if item.get('variant_id') == variant['id']:
|
| 1314 |
total_revenue += to_decimal(item['total'])
|
|
@@ -1438,7 +1441,7 @@ def manage_kassa():
|
|
| 1438 |
elif action == 'delete':
|
| 1439 |
kassa_id = request.form.get('id')
|
| 1440 |
initial_len = len(kassas)
|
| 1441 |
-
kassas =
|
| 1442 |
if len(kassas) < initial_len:
|
| 1443 |
flash("Касса удалена.", "success")
|
| 1444 |
else:
|
|
@@ -1476,7 +1479,7 @@ def kassa_operation():
|
|
| 1476 |
new_balance -= amount
|
| 1477 |
|
| 1478 |
kassas[i]['balance'] = str(new_balance)
|
| 1479 |
-
if 'history' not in kassas[i]: kassas[i]['history'] =
|
| 1480 |
kassas[i]['history'].append({
|
| 1481 |
'type': op_type,
|
| 1482 |
'amount': str(amount),
|
|
@@ -1560,7 +1563,7 @@ def manage_personal_expense():
|
|
| 1560 |
def delete_personal_expense(expense_id):
|
| 1561 |
expenses = load_json_data('personal_expenses')
|
| 1562 |
initial_len = len(expenses)
|
| 1563 |
-
expenses =
|
| 1564 |
if len(expenses) < initial_len:
|
| 1565 |
save_json_data('personal_expenses', expenses)
|
| 1566 |
upload_db_to_hf('personal_expenses')
|
|
@@ -1582,7 +1585,7 @@ def manage_link():
|
|
| 1582 |
flash("Ссылка добавлена.", "success")
|
| 1583 |
elif action == 'delete':
|
| 1584 |
link_id = request.form.get('id')
|
| 1585 |
-
links =
|
| 1586 |
flash("Ссылка удалена.", "success")
|
| 1587 |
save_json_data('links', links)
|
| 1588 |
upload_db_to_hf('links')
|
|
@@ -1679,7 +1682,7 @@ def end_shift():
|
|
| 1679 |
|
| 1680 |
shift['end_balance'] = kassa.get('balance', '0') if kassa else '0'
|
| 1681 |
|
| 1682 |
-
shift_transactions =
|
| 1683 |
t for t in transactions
|
| 1684 |
if t.get('shift_id') == shift_id and datetime.fromisoformat(t['timestamp']) >= datetime.fromisoformat(shift['start_time'])
|
| 1685 |
]
|
|
@@ -1740,7 +1743,7 @@ def return_transaction(transaction_id):
|
|
| 1740 |
flash("Не указан ID кассира.", "danger")
|
| 1741 |
return redirect(url_for('cashier_login'))
|
| 1742 |
|
| 1743 |
-
returnable_items =
|
| 1744 |
already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
|
| 1745 |
|
| 1746 |
for item in original_transaction['items']:
|
|
@@ -1761,7 +1764,7 @@ def return_transaction(transaction_id):
|
|
| 1761 |
flash("Не удалось определить кассира.", "danger")
|
| 1762 |
return redirect(url_for('cashier_login'))
|
| 1763 |
|
| 1764 |
-
return_items =
|
| 1765 |
total_return_amount = Decimal('0')
|
| 1766 |
inventory_updates = {}
|
| 1767 |
items_to_process = defaultdict(int)
|
|
@@ -1806,7 +1809,7 @@ def return_transaction(transaction_id):
|
|
| 1806 |
if not item.get('is_custom'):
|
| 1807 |
product = find_item_by_field(inventory, 'id', item['product_id'])
|
| 1808 |
if product:
|
| 1809 |
-
variant = find_item_by_field(product.get('variants',
|
| 1810 |
if variant:
|
| 1811 |
inventory_updates[variant_id] = {'product_id': item['product_id'], 'stock_change': qty_to_return}
|
| 1812 |
|
|
@@ -1841,7 +1844,7 @@ def return_transaction(transaction_id):
|
|
| 1841 |
for variant_id, update_info in inventory_updates.items():
|
| 1842 |
for p_idx, p in enumerate(inventory):
|
| 1843 |
if p.get('id') == update_info['product_id']:
|
| 1844 |
-
for v_idx, v in enumerate(p.get('variants',
|
| 1845 |
if v.get('id') == variant_id:
|
| 1846 |
inventory[p_idx]['variants'][v_idx]['stock'] += update_info['stock_change']
|
| 1847 |
inventory[p_idx]['timestamp_updated'] = now_iso
|
|
@@ -1853,7 +1856,7 @@ def return_transaction(transaction_id):
|
|
| 1853 |
if k.get('id') == original_transaction['kassa_id']:
|
| 1854 |
current_balance = to_decimal(k.get('balance', '0'))
|
| 1855 |
kassas[k_idx]['balance'] = str(current_balance - total_return_amount)
|
| 1856 |
-
kassas[k_idx].setdefault('history',
|
| 1857 |
'type': 'return', 'amount': str(-total_return_amount), 'timestamp': now_iso,
|
| 1858 |
'transaction_id': return_transaction_id
|
| 1859 |
})
|
|
@@ -1883,7 +1886,7 @@ def backup_hf():
|
|
| 1883 |
@app.route('/download', methods=['GET'])
|
| 1884 |
@admin_required
|
| 1885 |
def download_hf():
|
| 1886 |
-
errors =
|
| 1887 |
success_count = 0
|
| 1888 |
for key in DATA_FILES.keys():
|
| 1889 |
filepath, _ = DATA_FILES[key]
|
|
@@ -1963,7 +1966,7 @@ def restore_bill(bill_id):
|
|
| 1963 |
def delete_held_bill(bill_id):
|
| 1964 |
bills = load_json_data('held_bills')
|
| 1965 |
initial_len = len(bills)
|
| 1966 |
-
bills =
|
| 1967 |
if len(bills) < initial_len:
|
| 1968 |
save_json_data('held_bills', bills)
|
| 1969 |
upload_db_to_hf('held_bills')
|
|
@@ -2001,7 +2004,7 @@ def manage_customer():
|
|
| 2001 |
elif action == 'delete':
|
| 2002 |
customer_id = request.form.get('id')
|
| 2003 |
initial_len = len(customers)
|
| 2004 |
-
customers =
|
| 2005 |
if len(customers) < initial_len:
|
| 2006 |
flash("Клиент удален.", "success")
|
| 2007 |
else:
|
|
@@ -2017,7 +2020,7 @@ def search_customers():
|
|
| 2017 |
if not query:
|
| 2018 |
return jsonify([])
|
| 2019 |
customers = load_json_data('customers')
|
| 2020 |
-
matches =
|
| 2021 |
c for c in customers
|
| 2022 |
if query in c.get('name', '').lower() or query in c.get('phone', '')
|
| 2023 |
]
|
|
@@ -2063,13 +2066,10 @@ BASE_TEMPLATE = """
|
|
| 2063 |
.sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); transition: transform 0.3s ease-in-out; z-index: 1040; }
|
| 2064 |
.sidebar.active { transform: translateX(0); }
|
| 2065 |
.main-content { margin-left: 0; }
|
| 2066 |
-
}
|
| 2067 |
-
[data-bs-theme="dark"]
|
| 2068 |
-
[data-bs-theme="dark"] .card, [data-bs-theme="dark"] .modal-content, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table, [data-bs-theme="dark"] .accordion-item { background-color: #343a40; }
|
| 2069 |
-
[data-bs-theme="dark"] .accordion-button { background-color: #3e444a; color: #fff; }
|
| 2070 |
[data-bs-theme="dark"] .accordion-button:not(.collapsed) { background-color: #495057;}
|
| 2071 |
-
[data-bs-theme="dark"] .accordion-button::after { filter: invert(1) grayscale(100) brightness(200%); }
|
| 2072 |
-
[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); }
|
| 2073 |
[data-bs-theme="dark"] .text-dark { color: #dee2e6 !important; }
|
| 2074 |
.product-card { cursor: pointer; }
|
| 2075 |
.product-card:hover { border-color: var(--bs-primary); }
|
|
@@ -2100,7 +2100,7 @@ BASE_TEMPLATE = """
|
|
| 2100 |
</li>
|
| 2101 |
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'customer_management' %}active{% endif %}" href="{{ url_for('customer_management') }}"><i class="fas fa-fw fa-users me-2"></i>Клиенты</a></li>
|
| 2102 |
<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>
|
| 2103 |
-
<li class="nav-item"><a class="nav-link {% if request.endpoint in
|
| 2104 |
|
| 2105 |
{% if not session.admin_logged_in %}
|
| 2106 |
<li class="nav-item mt-3"><a class="nav-link text-warning" href="{{ url_for('admin_login') }}"><i class="fas fa-fw fa-user-shield me-2"></i>Войти как админ</a></li>
|
|
@@ -2339,36 +2339,33 @@ SALES_SCREEN_CONTENT = """
|
|
| 2339 |
<div id="scanner-status" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.5); color: white; padding: 5px; border-radius: 5px; display: none;"></div>
|
| 2340 |
<button id="stop-scan-btn" class="btn btn-danger btn-sm mt-2">Остановить</button>
|
| 2341 |
</div>
|
| 2342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2343 |
<div id="product-search-results" class="d-grid gap-2" style="display: none; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"></div>
|
| 2344 |
|
| 2345 |
-
<div id="product-
|
| 2346 |
-
{% for
|
| 2347 |
-
<div class="
|
| 2348 |
-
<
|
| 2349 |
-
<
|
| 2350 |
-
|
| 2351 |
-
|
| 2352 |
-
|
| 2353 |
-
|
| 2354 |
-
|
| 2355 |
-
{%
|
| 2356 |
-
|
| 2357 |
-
|
| 2358 |
-
|
| 2359 |
-
<p class="card-text fw-bold mb-0">
|
| 2360 |
-
{% if p.variants|length > 1 %}
|
| 2361 |
-
от {{ format_currency_py(p.variants|map(attribute='price_regular')|min) }} ₸
|
| 2362 |
-
{% elif p.variants|length == 1 %}
|
| 2363 |
-
{{ format_currency_py(p.variants[0].get('price_regular', p.variants[0].get('price'))) }} ₸
|
| 2364 |
-
{% else %}
|
| 2365 |
-
Нет в наличии
|
| 2366 |
-
{% endif %}
|
| 2367 |
-
</p>
|
| 2368 |
-
</div>
|
| 2369 |
-
</div>
|
| 2370 |
-
{% endfor %}
|
| 2371 |
-
</div>
|
| 2372 |
</div>
|
| 2373 |
</div>
|
| 2374 |
{% endfor %}
|
|
@@ -2392,6 +2389,7 @@ SALES_SCREEN_CONTENT = """
|
|
| 2392 |
</div>
|
| 2393 |
<div id="cart-items" class="list-group mb-3" style="max-height: 400px; overflow-y: auto;"></div>
|
| 2394 |
<div class="mb-3">
|
|
|
|
| 2395 |
<div class="d-flex justify-content-between"><span>Подытог:</span><span id="cart-subtotal">0 ₸</span></div>
|
| 2396 |
<div class="d-flex justify-content-between"><span>Доставка:</span><span id="cart-delivery">0 ₸</span></div>
|
| 2397 |
<hr class="my-1">
|
|
@@ -2503,6 +2501,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2503 |
const cartSubtotalEl = document.getElementById('cart-subtotal');
|
| 2504 |
const cartDeliveryEl = document.getElementById('cart-delivery');
|
| 2505 |
const cartTotalEl = document.getElementById('cart-total');
|
|
|
|
| 2506 |
|
| 2507 |
let audioCtx;
|
| 2508 |
let isScannerPaused = false;
|
|
@@ -2585,12 +2584,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2585 |
const updateCartView = () => {
|
| 2586 |
cartItemsEl.innerHTML = '';
|
| 2587 |
let subtotal = 0;
|
|
|
|
| 2588 |
if (Object.keys(cart).length === 0) {
|
| 2589 |
cartItemsEl.innerHTML = '<p class="text-center text-muted">Корзина пуста</p>';
|
| 2590 |
}
|
| 2591 |
for (const id in cart) {
|
| 2592 |
const item = cart[id];
|
| 2593 |
subtotal += (parseLocaleNumber(item.price) - parseLocaleNumber(item.discount)) * item.quantity;
|
|
|
|
| 2594 |
|
| 2595 |
let nameField = '';
|
| 2596 |
let priceField = '';
|
|
@@ -2634,6 +2635,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2634 |
cartSubtotalEl.textContent = formatCurrencyJS(subtotal) + ' ₸';
|
| 2635 |
cartDeliveryEl.textContent = formatCurrencyJS(deliveryCost) + ' ₸';
|
| 2636 |
cartTotalEl.textContent = formatCurrencyJS(total) + ' ₸';
|
|
|
|
| 2637 |
};
|
| 2638 |
|
| 2639 |
const addToCart = (product, variant, price, priceType) => {
|
|
@@ -2659,7 +2661,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2659 |
const container = document.getElementById('price-options-container');
|
| 2660 |
container.innerHTML = '';
|
| 2661 |
|
| 2662 |
-
const prices =
|
| 2663 |
{ type: 'Общая', value: variant.price_regular || variant.price },
|
| 2664 |
{ type: 'Минимальная', value: variant.price_min },
|
| 2665 |
{ type: 'Оптовая', value: variant.price_wholesale }
|
|
@@ -2799,24 +2801,49 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2799 |
});
|
| 2800 |
}
|
| 2801 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2802 |
const productSearchInput = document.getElementById('product-search');
|
| 2803 |
-
const
|
|
|
|
| 2804 |
const productSearchResultsEl = document.getElementById('product-search-results');
|
| 2805 |
|
| 2806 |
productSearchInput.addEventListener('input', e => {
|
| 2807 |
const term = e.target.value.toLowerCase().trim();
|
| 2808 |
|
| 2809 |
if (term === '') {
|
| 2810 |
-
|
|
|
|
| 2811 |
productSearchResultsEl.style.display = 'none';
|
| 2812 |
-
|
| 2813 |
-
|
| 2814 |
-
bootstrap.Collapse.getOrCreateInstance(el).hide();
|
| 2815 |
-
});
|
| 2816 |
return;
|
| 2817 |
}
|
| 2818 |
|
| 2819 |
-
|
|
|
|
| 2820 |
productSearchResultsEl.style.display = 'grid';
|
| 2821 |
|
| 2822 |
const filtered = allProducts.filter(p => p.name.toLowerCase().includes(term) || p.barcode.toLowerCase().includes(term));
|
|
@@ -4452,7 +4479,8 @@ ADMIN_CONTENT = """
|
|
| 4452 |
<td class="text-end fw-bold">{{ format_currency_py(e.amount) }} ₸</td>
|
| 4453 |
<td class="text-end">
|
| 4454 |
<form action="{{ url_for('delete_personal_expense', expense_id=e.id) }}" method="POST" onsubmit="return confirm('Удалить этот расход?');">
|
| 4455 |
-
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1"><i class="fas fa-trash">
|
|
|
|
| 4456 |
</form>
|
| 4457 |
</td>
|
| 4458 |
</tr>
|
|
@@ -4626,7 +4654,7 @@ CASHIER_DASHBOARD_CONTENT = """
|
|
| 4626 |
{% endif %}
|
| 4627 |
</td>
|
| 4628 |
<td>
|
| 4629 |
-
{% if t.type == 'sale' and t.status in
|
| 4630 |
<a href="{{ url_for('return_transaction', transaction_id=t.id, cashier_id=user.id) }}" class="btn btn-sm btn-warning">Возврат</a>
|
| 4631 |
{% endif %}
|
| 4632 |
</td>
|
|
@@ -4731,7 +4759,7 @@ CUSTOMERS_CONTENT = """
|
|
| 4731 |
</div>
|
| 4732 |
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
|
| 4733 |
</form>
|
| 4734 |
-
</div>
|
| 4735 |
</div>
|
| 4736 |
</div>
|
| 4737 |
"""
|
|
|
|
| 95 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 96 |
return json.load(f)
|
| 97 |
except (FileNotFoundError, json.JSONDecodeError):
|
| 98 |
+
return[]
|
| 99 |
|
| 100 |
def save_json_data(file_key, data):
|
| 101 |
filepath, lock = DATA_FILES[file_key]
|
|
|
|
| 162 |
return "0"
|
| 163 |
|
| 164 |
def generate_receipt_html(transaction):
|
| 165 |
+
has_discounts = any(to_decimal(item.get('discount_per_item', '0')) > 0 for item in transaction.get('items',[]))
|
| 166 |
delivery_cost = to_decimal(transaction.get('delivery_cost', '0'))
|
| 167 |
note = transaction.get('note', '')
|
| 168 |
|
| 169 |
if has_discounts:
|
| 170 |
table_headers = """
|
| 171 |
+
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 172 |
+
<th style="text-align: left; white-space: nowrap;">Наименование</th>
|
| 173 |
+
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 174 |
+
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 175 |
+
<th style="text-align: right; white-space: nowrap;">Скидка</th>
|
| 176 |
+
<th style="text-align: right; white-space: nowrap;">Сумма</th>
|
| 177 |
"""
|
| 178 |
total_colspan = 5
|
| 179 |
else:
|
| 180 |
table_headers = """
|
| 181 |
+
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 182 |
+
<th style="text-align: left; white-space: nowrap;">Наименование</th>
|
| 183 |
+
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 184 |
+
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 185 |
+
<th style="text-align: right; white-space: nowrap;">Сумма</th>
|
| 186 |
"""
|
| 187 |
total_colspan = 4
|
| 188 |
|
| 189 |
items_html = ""
|
| 190 |
+
total_quantity = 0
|
| 191 |
for i, item in enumerate(transaction['items']):
|
| 192 |
+
total_quantity += int(item.get('quantity', 0))
|
| 193 |
+
discount_cell = f"""<td style="text-align: right; white-space: nowrap;">{format_currency_py(item.get('discount_per_item', '0'))}</td>""" if has_discounts else ""
|
| 194 |
items_html += f"""
|
| 195 |
<tr>
|
| 196 |
+
<td style="text-align: center; white-space: nowrap;">{i + 1}</td>
|
| 197 |
+
<td style="white-space: nowrap;">{item['name']}</td>
|
| 198 |
+
<td style="text-align: right; white-space: nowrap;">{item['quantity']}</td>
|
| 199 |
+
<td style="text-align: right; white-space: nowrap;">{format_currency_py(item['price_at_sale'])}</td>
|
| 200 |
{discount_cell}
|
| 201 |
+
<td style="text-align: right; white-space: nowrap;">{format_currency_py(item['total'])}</td>
|
| 202 |
</tr>
|
| 203 |
"""
|
| 204 |
|
| 205 |
total_amount_from_db = to_decimal(transaction['total_amount'])
|
| 206 |
|
| 207 |
totals_html = ""
|
| 208 |
+
totals_html += f"""
|
| 209 |
+
<tr>
|
| 210 |
+
<td colspan="{total_colspan}" style="text-align: right; white-space: nowrap;">Общее кол-во единиц:</td>
|
| 211 |
+
<td style="text-align: right; white-space: nowrap;">{total_quantity}</td>
|
| 212 |
+
</tr>
|
| 213 |
+
"""
|
| 214 |
+
|
| 215 |
if delivery_cost > 0:
|
| 216 |
subtotal = total_amount_from_db - delivery_cost
|
| 217 |
totals_html += f"""
|
| 218 |
<tr>
|
| 219 |
+
<td colspan="{total_colspan}" style="text-align: right; white-space: nowrap;">Подытог:</td>
|
| 220 |
+
<td style="text-align: right; white-space: nowrap;">{format_currency_py(subtotal)} ₸</td>
|
| 221 |
</tr>
|
| 222 |
<tr>
|
| 223 |
+
<td colspan="{total_colspan}" style="text-align: right; white-space: nowrap;">Доставка:</td>
|
| 224 |
+
<td style="text-align: right; white-space: nowrap;">{format_currency_py(delivery_cost)} ₸</td>
|
| 225 |
</tr>
|
| 226 |
"""
|
| 227 |
|
| 228 |
totals_html += f"""
|
| 229 |
<tr class="total">
|
| 230 |
+
<td colspan="{total_colspan}" style="text-align: right; white-space: nowrap;">Итого к оплате:</td>
|
| 231 |
+
<td style="text-align: right; white-space: nowrap;">{format_currency_py(total_amount_from_db)} ₸</td>
|
| 232 |
</tr>
|
| 233 |
"""
|
| 234 |
|
|
|
|
| 265 |
.header p {{ margin: 2px 0; font-size: 14px; }}
|
| 266 |
.details-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; font-size: 14px; }}
|
| 267 |
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
|
| 268 |
+
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; white-space: nowrap; }}
|
| 269 |
+
table td {{ padding: 8px; border-bottom: 1px solid #eee; white-space: nowrap; }}
|
| 270 |
table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
|
| 271 |
.footer-info {{ font-size: 14px; margin-top: 20px; }}
|
| 272 |
.print-hide {{ display: block; }}
|
|
|
|
| 369 |
transactions = load_json_data('transactions')
|
| 370 |
edit_tx = find_item_by_field(transactions, 'id', edit_tx_id)
|
| 371 |
|
| 372 |
+
active_inventory =[]
|
| 373 |
for p in inventory:
|
| 374 |
+
if isinstance(p, dict) and any(v.get('stock', 0) > 0 for v in p.get('variants',[])):
|
| 375 |
active_inventory.append(p)
|
| 376 |
|
| 377 |
active_inventory.sort(key=lambda x: x.get('name', '').lower())
|
| 378 |
+
letters = sorted(list(set([p.get('name', '#')[0].upper() for p in active_inventory])))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
html = BASE_TEMPLATE.replace('__TITLE__', "Касса").replace('__CONTENT__', SALES_SCREEN_CONTENT).replace('__SCRIPTS__', SALES_SCREEN_SCRIPTS)
|
| 381 |
+
return render_template_string(html, inventory=active_inventory, kassas=kassas, letters=letters, edit_tx=edit_tx)
|
| 382 |
|
| 383 |
@app.route('/inventory', methods=['GET', 'POST'])
|
| 384 |
def inventory_management():
|
|
|
|
| 464 |
|
| 465 |
for product in inventory_list:
|
| 466 |
if isinstance(product, dict) and 'variants' in product:
|
| 467 |
+
for variant in product.get('variants',[]):
|
| 468 |
stock = variant.get('stock', 0)
|
| 469 |
cost_price = to_decimal(variant.get('cost_price', '0'))
|
| 470 |
price = to_decimal(variant.get('price_regular', variant.get('price', '0')))
|
|
|
|
| 591 |
def delete_product(product_id):
|
| 592 |
inventory = load_json_data('inventory')
|
| 593 |
initial_len = len(inventory)
|
| 594 |
+
inventory =[p for p in inventory if not (isinstance(p, dict) and p.get('id') == product_id)]
|
| 595 |
if len(inventory) < initial_len:
|
| 596 |
save_json_data('inventory', inventory)
|
| 597 |
upload_db_to_hf('inventory')
|
|
|
|
| 623 |
|
| 624 |
variant_found = False
|
| 625 |
variant_name_for_log = ""
|
| 626 |
+
for i, variant in enumerate(product.get('variants',[])):
|
| 627 |
if variant.get('id') == variant_id:
|
| 628 |
variant_name_for_log = variant.get('option_value', '')
|
| 629 |
|
|
|
|
| 707 |
inventory = load_json_data('inventory')
|
| 708 |
product = find_item_by_field(inventory, 'barcode', barcode)
|
| 709 |
if product:
|
| 710 |
+
active_variants =[v for v in product.get('variants', []) if v.get('stock', 0) > 0]
|
| 711 |
if active_variants:
|
| 712 |
product_copy = product.copy()
|
| 713 |
product_copy['variants'] = active_variants
|
|
|
|
| 737 |
if not original_tx:
|
| 738 |
return jsonify({'success': False, 'message': 'Оригинальная накладная не найдена.'}), 404
|
| 739 |
|
| 740 |
+
for item in original_tx.get('items',[]):
|
| 741 |
if not item.get('is_custom'):
|
| 742 |
for p in inventory:
|
| 743 |
if p.get('id') == item.get('product_id'):
|
| 744 |
+
for v in p.get('variants',[]):
|
| 745 |
if v.get('id') == item.get('variant_id'):
|
| 746 |
v['stock'] = v.get('stock', 0) + item.get('quantity', 0)
|
| 747 |
break
|
|
|
|
| 753 |
current_balance = to_decimal(k.get('balance', '0'))
|
| 754 |
amount = to_decimal(original_tx.get('total_amount', '0'))
|
| 755 |
k['balance'] = str(current_balance - amount)
|
| 756 |
+
k.setdefault('history',[]).append({
|
| 757 |
'type': 'correction_revert',
|
| 758 |
'amount': str(-amount),
|
| 759 |
'timestamp': get_current_time().isoformat(),
|
|
|
|
| 777 |
if not user or not kassa:
|
| 778 |
return jsonify({'success': False, 'message': 'Кассир или касса были удалены. Требуется повторный вход.', 'logout_required': True}), 401
|
| 779 |
|
| 780 |
+
sale_items =[]
|
| 781 |
items_total = Decimal('0')
|
| 782 |
inventory_updates = {}
|
| 783 |
|
|
|
|
| 806 |
if not product:
|
| 807 |
return jsonify({'success': False, 'message': f"Товар с ID {cart_item['productId']} не найден."}), 404
|
| 808 |
|
| 809 |
+
variant = find_item_by_field(product.get('variants',[]), 'id', variant_id)
|
| 810 |
if not variant:
|
| 811 |
return jsonify({'success': False, 'message': f"Вариант товара с ID {variant_id} не найден."}), 404
|
| 812 |
|
|
|
|
| 865 |
}
|
| 866 |
|
| 867 |
if edit_tx_id:
|
| 868 |
+
new_transaction['edits'] = original_tx.get('edits', []) +[{'timestamp': now_iso, 'type': 'full_edit'}]
|
| 869 |
|
| 870 |
new_transaction['invoice_html'] = generate_receipt_html(new_transaction)
|
| 871 |
|
|
|
|
| 880 |
for variant_id, update_info in inventory_updates.items():
|
| 881 |
for p in inventory:
|
| 882 |
if p.get('id') == update_info['product_id']:
|
| 883 |
+
for v in p.get('variants',[]):
|
| 884 |
if v.get('id') == variant_id:
|
| 885 |
v['stock'] = update_info['new_stock']
|
| 886 |
p['timestamp_updated'] = now_iso
|
|
|
|
| 953 |
]
|
| 954 |
|
| 955 |
if selected_kassa_id:
|
| 956 |
+
filtered_transactions =[
|
| 957 |
t for t in filtered_transactions
|
| 958 |
if t.get('kassa_id') == selected_kassa_id
|
| 959 |
]
|
|
|
|
| 963 |
total_quantity_sold = 0
|
| 964 |
for t in filtered_transactions:
|
| 965 |
if t.get('type') == 'sale':
|
| 966 |
+
for item in t.get('items',[]):
|
| 967 |
total_quantity_sold += int(item.get('quantity', 0))
|
| 968 |
|
| 969 |
filtered_transactions.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
|
|
|
| 976 |
def edit_transaction(transaction_id):
|
| 977 |
try:
|
| 978 |
data = request.get_json()
|
| 979 |
+
items_update = data.get('items',[])
|
| 980 |
|
| 981 |
transactions = load_json_data('transactions')
|
| 982 |
kassas = load_json_data('kassas')
|
|
|
|
| 1033 |
for i, k in enumerate(kassas):
|
| 1034 |
if k.get('id') == kassa_id:
|
| 1035 |
k['balance'] = str(to_decimal(k.get('balance', '0')) + amount_diff)
|
| 1036 |
+
k.setdefault('history',[]).append({
|
| 1037 |
'type': 'correction',
|
| 1038 |
'amount': str(amount_diff),
|
| 1039 |
'timestamp': get_current_time().isoformat(),
|
|
|
|
| 1064 |
flash("Транзакция не найдена.", "danger")
|
| 1065 |
return redirect(url_for('transaction_history'))
|
| 1066 |
|
| 1067 |
+
for item in transaction_to_delete.get('items',[]):
|
| 1068 |
if item.get('is_custom'):
|
| 1069 |
continue
|
| 1070 |
|
| 1071 |
product = find_item_by_field(inventory, 'id', item.get('product_id'))
|
| 1072 |
if not product: continue
|
| 1073 |
|
| 1074 |
+
variant = find_item_by_field(product.get('variants',[]), 'id', item.get('variant_id'))
|
| 1075 |
if not variant: continue
|
| 1076 |
|
| 1077 |
quantity_change = item.get('quantity', 0)
|
|
|
|
| 1089 |
|
| 1090 |
kassa['balance'] = str(current_balance - amount_change)
|
| 1091 |
|
| 1092 |
+
kassa.setdefault('history',[]).append({
|
| 1093 |
'type': 'deletion',
|
| 1094 |
'amount': str(-amount_change),
|
| 1095 |
'timestamp': get_current_time().isoformat(),
|
| 1096 |
'description': f"Удаление транзакции {transaction_id[:8]}"
|
| 1097 |
})
|
| 1098 |
|
| 1099 |
+
transactions =[t for t in transactions if t.get('id') != transaction_id]
|
| 1100 |
|
| 1101 |
if transaction_to_delete.get('type') == 'return':
|
| 1102 |
original_id = transaction_to_delete.get('original_transaction_id')
|
|
|
|
| 1133 |
personal_expenses = load_json_data('personal_expenses')
|
| 1134 |
users = load_json_data('users')
|
| 1135 |
|
| 1136 |
+
filtered_transactions =[
|
| 1137 |
t for t in transactions
|
| 1138 |
if start_date <= datetime.fromisoformat(t['timestamp']) < end_date
|
| 1139 |
]
|
| 1140 |
+
filtered_expenses =[
|
| 1141 |
e for e in expenses
|
| 1142 |
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
|
| 1143 |
]
|
| 1144 |
+
filtered_personal_expenses =[
|
| 1145 |
e for e in personal_expenses
|
| 1146 |
if start_date <= datetime.fromisoformat(e['timestamp']) < end_date
|
| 1147 |
]
|
| 1148 |
|
| 1149 |
+
return_transactions =[t for t in filtered_transactions if t.get('type') == 'return']
|
| 1150 |
total_returns_amount = sum(to_decimal(t['total_amount']) for t in return_transactions)
|
| 1151 |
total_returns_count = len(return_transactions)
|
| 1152 |
|
|
|
|
| 1225 |
end_date = (datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1)).replace(tzinfo=ALMATY_TZ)
|
| 1226 |
|
| 1227 |
transactions = load_json_data('transactions')
|
| 1228 |
+
employee_transactions =[]
|
| 1229 |
totals = {'sales': Decimal(0), 'returns': Decimal(0), 'net': Decimal(0)}
|
| 1230 |
|
| 1231 |
if user_id:
|
|
|
|
| 1257 |
product_id = request.args.get('product_id', '')
|
| 1258 |
variant_id = request.args.get('variant_id', '')
|
| 1259 |
|
| 1260 |
+
movements =[]
|
| 1261 |
selected_product = None
|
| 1262 |
selected_variant = None
|
| 1263 |
|
| 1264 |
if product_id:
|
| 1265 |
selected_product = find_item_by_field(inventory, 'id', product_id)
|
| 1266 |
if selected_product and variant_id:
|
| 1267 |
+
selected_variant = find_item_by_field(selected_product.get('variants',[]), 'id', variant_id)
|
| 1268 |
|
| 1269 |
for t in transactions:
|
| 1270 |
+
for item in t.get('items',[]):
|
| 1271 |
if item.get('product_id') == product_id and (not variant_id or item.get('variant_id') == variant_id):
|
| 1272 |
movements.append({
|
| 1273 |
'timestamp': t['timestamp'],
|
|
|
|
| 1305 |
product_stats = []
|
| 1306 |
|
| 1307 |
for product in inventory:
|
| 1308 |
+
for variant in product.get('variants',[]):
|
| 1309 |
total_revenue = Decimal('0')
|
| 1310 |
total_cogs = Decimal('0')
|
| 1311 |
total_qty_sold = 0
|
| 1312 |
|
| 1313 |
for t in transactions:
|
| 1314 |
+
if t['type'] in['sale', 'return']:
|
| 1315 |
for item in t['items']:
|
| 1316 |
if item.get('variant_id') == variant['id']:
|
| 1317 |
total_revenue += to_decimal(item['total'])
|
|
|
|
| 1441 |
elif action == 'delete':
|
| 1442 |
kassa_id = request.form.get('id')
|
| 1443 |
initial_len = len(kassas)
|
| 1444 |
+
kassas =[k for k in kassas if k.get('id') != kassa_id]
|
| 1445 |
if len(kassas) < initial_len:
|
| 1446 |
flash("Касса удалена.", "success")
|
| 1447 |
else:
|
|
|
|
| 1479 |
new_balance -= amount
|
| 1480 |
|
| 1481 |
kassas[i]['balance'] = str(new_balance)
|
| 1482 |
+
if 'history' not in kassas[i]: kassas[i]['history'] =[]
|
| 1483 |
kassas[i]['history'].append({
|
| 1484 |
'type': op_type,
|
| 1485 |
'amount': str(amount),
|
|
|
|
| 1563 |
def delete_personal_expense(expense_id):
|
| 1564 |
expenses = load_json_data('personal_expenses')
|
| 1565 |
initial_len = len(expenses)
|
| 1566 |
+
expenses =[e for e in expenses if e.get('id') != expense_id]
|
| 1567 |
if len(expenses) < initial_len:
|
| 1568 |
save_json_data('personal_expenses', expenses)
|
| 1569 |
upload_db_to_hf('personal_expenses')
|
|
|
|
| 1585 |
flash("Ссылка добавлена.", "success")
|
| 1586 |
elif action == 'delete':
|
| 1587 |
link_id = request.form.get('id')
|
| 1588 |
+
links =[l for l in links if l.get('id') != link_id]
|
| 1589 |
flash("Ссылка удалена.", "success")
|
| 1590 |
save_json_data('links', links)
|
| 1591 |
upload_db_to_hf('links')
|
|
|
|
| 1682 |
|
| 1683 |
shift['end_balance'] = kassa.get('balance', '0') if kassa else '0'
|
| 1684 |
|
| 1685 |
+
shift_transactions =[
|
| 1686 |
t for t in transactions
|
| 1687 |
if t.get('shift_id') == shift_id and datetime.fromisoformat(t['timestamp']) >= datetime.fromisoformat(shift['start_time'])
|
| 1688 |
]
|
|
|
|
| 1743 |
flash("Не указан ID кассира.", "danger")
|
| 1744 |
return redirect(url_for('cashier_login'))
|
| 1745 |
|
| 1746 |
+
returnable_items =[]
|
| 1747 |
already_returned = original_transaction.get('return_info', {}).get('returned_items', {})
|
| 1748 |
|
| 1749 |
for item in original_transaction['items']:
|
|
|
|
| 1764 |
flash("Не удалось определить кассира.", "danger")
|
| 1765 |
return redirect(url_for('cashier_login'))
|
| 1766 |
|
| 1767 |
+
return_items =[]
|
| 1768 |
total_return_amount = Decimal('0')
|
| 1769 |
inventory_updates = {}
|
| 1770 |
items_to_process = defaultdict(int)
|
|
|
|
| 1809 |
if not item.get('is_custom'):
|
| 1810 |
product = find_item_by_field(inventory, 'id', item['product_id'])
|
| 1811 |
if product:
|
| 1812 |
+
variant = find_item_by_field(product.get('variants',[]), 'id', variant_id)
|
| 1813 |
if variant:
|
| 1814 |
inventory_updates[variant_id] = {'product_id': item['product_id'], 'stock_change': qty_to_return}
|
| 1815 |
|
|
|
|
| 1844 |
for variant_id, update_info in inventory_updates.items():
|
| 1845 |
for p_idx, p in enumerate(inventory):
|
| 1846 |
if p.get('id') == update_info['product_id']:
|
| 1847 |
+
for v_idx, v in enumerate(p.get('variants',[])):
|
| 1848 |
if v.get('id') == variant_id:
|
| 1849 |
inventory[p_idx]['variants'][v_idx]['stock'] += update_info['stock_change']
|
| 1850 |
inventory[p_idx]['timestamp_updated'] = now_iso
|
|
|
|
| 1856 |
if k.get('id') == original_transaction['kassa_id']:
|
| 1857 |
current_balance = to_decimal(k.get('balance', '0'))
|
| 1858 |
kassas[k_idx]['balance'] = str(current_balance - total_return_amount)
|
| 1859 |
+
kassas[k_idx].setdefault('history',[]).append({
|
| 1860 |
'type': 'return', 'amount': str(-total_return_amount), 'timestamp': now_iso,
|
| 1861 |
'transaction_id': return_transaction_id
|
| 1862 |
})
|
|
|
|
| 1886 |
@app.route('/download', methods=['GET'])
|
| 1887 |
@admin_required
|
| 1888 |
def download_hf():
|
| 1889 |
+
errors =[]
|
| 1890 |
success_count = 0
|
| 1891 |
for key in DATA_FILES.keys():
|
| 1892 |
filepath, _ = DATA_FILES[key]
|
|
|
|
| 1966 |
def delete_held_bill(bill_id):
|
| 1967 |
bills = load_json_data('held_bills')
|
| 1968 |
initial_len = len(bills)
|
| 1969 |
+
bills =[b for b in bills if b.get('id') != bill_id]
|
| 1970 |
if len(bills) < initial_len:
|
| 1971 |
save_json_data('held_bills', bills)
|
| 1972 |
upload_db_to_hf('held_bills')
|
|
|
|
| 2004 |
elif action == 'delete':
|
| 2005 |
customer_id = request.form.get('id')
|
| 2006 |
initial_len = len(customers)
|
| 2007 |
+
customers =[c for c in customers if c.get('id') != customer_id]
|
| 2008 |
if len(customers) < initial_len:
|
| 2009 |
flash("Клиент удален.", "success")
|
| 2010 |
else:
|
|
|
|
| 2020 |
if not query:
|
| 2021 |
return jsonify([])
|
| 2022 |
customers = load_json_data('customers')
|
| 2023 |
+
matches =[
|
| 2024 |
c for c in customers
|
| 2025 |
if query in c.get('name', '').lower() or query in c.get('phone', '')
|
| 2026 |
]
|
|
|
|
| 2066 |
.sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); transition: transform 0.3s ease-in-out; z-index: 1040; }
|
| 2067 |
.sidebar.active { transform: translateX(0); }
|
| 2068 |
.main-content { margin-left: 0; }
|
| 2069 |
+
}[data-bs-theme="dark"] body { background-color: #212529; color: #dee2e6; }
|
| 2070 |
+
[data-bs-theme="dark"] .card,[data-bs-theme="dark"] .modal-content, [data-bs-theme="dark"] .list-group-item, [data-bs-theme="dark"] .table, [data-bs-theme="dark"] .accordion-item { background-color: #343a40; }[data-bs-theme="dark"] .accordion-button { background-color: #3e444a; color: #fff; }
|
|
|
|
|
|
|
| 2071 |
[data-bs-theme="dark"] .accordion-button:not(.collapsed) { background-color: #495057;}
|
| 2072 |
+
[data-bs-theme="dark"] .accordion-button::after { filter: invert(1) grayscale(100) brightness(200%); }[data-bs-theme="dark"] .table-hover>tbody>tr:hover>* { color: var(--bs-table-hover-color); background-color: rgba(255, 255, 255, 0.075); }
|
|
|
|
| 2073 |
[data-bs-theme="dark"] .text-dark { color: #dee2e6 !important; }
|
| 2074 |
.product-card { cursor: pointer; }
|
| 2075 |
.product-card:hover { border-color: var(--bs-primary); }
|
|
|
|
| 2100 |
</li>
|
| 2101 |
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'customer_management' %}active{% endif %}" href="{{ url_for('customer_management') }}"><i class="fas fa-fw fa-users me-2"></i>Клиенты</a></li>
|
| 2102 |
<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>
|
| 2103 |
+
<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>
|
| 2104 |
|
| 2105 |
{% if not session.admin_logged_in %}
|
| 2106 |
<li class="nav-item mt-3"><a class="nav-link text-warning" href="{{ url_for('admin_login') }}"><i class="fas fa-fw fa-user-shield me-2"></i>Войти как админ</a></li>
|
|
|
|
| 2339 |
<div id="scanner-status" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.5); color: white; padding: 5px; border-radius: 5px; display: none;"></div>
|
| 2340 |
<button id="stop-scan-btn" class="btn btn-danger btn-sm mt-2">Остановить</button>
|
| 2341 |
</div>
|
| 2342 |
+
|
| 2343 |
+
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start mb-3 gap-2">
|
| 2344 |
+
<input type="text" id="product-search" class="form-control flex-grow-1" style="max-width: 400px;" placeholder="Поиск по названию или штрих-коду...">
|
| 2345 |
+
<div class="d-flex flex-wrap gap-1 justify-content-md-end" id="alphabet-filter">
|
| 2346 |
+
<button class="btn btn-secondary btn-sm filter-btn active" data-letter="all">Все</button>
|
| 2347 |
+
{% for letter in letters %}
|
| 2348 |
+
<button class="btn btn-outline-secondary btn-sm filter-btn" data-letter="{{ letter }}">{{ letter }}</button>
|
| 2349 |
+
{% endfor %}
|
| 2350 |
+
</div>
|
| 2351 |
+
</div>
|
| 2352 |
+
|
| 2353 |
<div id="product-search-results" class="d-grid gap-2" style="display: none; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"></div>
|
| 2354 |
|
| 2355 |
+
<div id="product-grid" class="d-grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));">
|
| 2356 |
+
{% for p in inventory %}
|
| 2357 |
+
<div class="card text-center product-card" data-barcode="{{ p.barcode }}" data-letter="{{ p.name[0]|upper }}">
|
| 2358 |
+
<div class="card-body p-2">
|
| 2359 |
+
<h6 class="card-title small mb-1">{{ p.name }}</h6>
|
| 2360 |
+
<p class="card-text fw-bold mb-0">
|
| 2361 |
+
{% if p.variants|length > 1 %}
|
| 2362 |
+
от {{ format_currency_py(p.variants|map(attribute='price_regular')|min) }} ₸
|
| 2363 |
+
{% elif p.variants|length == 1 %}
|
| 2364 |
+
{{ format_currency_py(p.variants[0].get('price_regular', p.variants[0].get('price'))) }} ₸
|
| 2365 |
+
{% else %}
|
| 2366 |
+
Нет в наличии
|
| 2367 |
+
{% endif %}
|
| 2368 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2369 |
</div>
|
| 2370 |
</div>
|
| 2371 |
{% endfor %}
|
|
|
|
| 2389 |
</div>
|
| 2390 |
<div id="cart-items" class="list-group mb-3" style="max-height: 400px; overflow-y: auto;"></div>
|
| 2391 |
<div class="mb-3">
|
| 2392 |
+
<div class="d-flex justify-content-between"><span>Общее кол-во единиц:</span><span id="cart-total-qty">0 шт.</span></div>
|
| 2393 |
<div class="d-flex justify-content-between"><span>Подытог:</span><span id="cart-subtotal">0 ₸</span></div>
|
| 2394 |
<div class="d-flex justify-content-between"><span>Доставка:</span><span id="cart-delivery">0 ₸</span></div>
|
| 2395 |
<hr class="my-1">
|
|
|
|
| 2501 |
const cartSubtotalEl = document.getElementById('cart-subtotal');
|
| 2502 |
const cartDeliveryEl = document.getElementById('cart-delivery');
|
| 2503 |
const cartTotalEl = document.getElementById('cart-total');
|
| 2504 |
+
const cartTotalQtyEl = document.getElementById('cart-total-qty');
|
| 2505 |
|
| 2506 |
let audioCtx;
|
| 2507 |
let isScannerPaused = false;
|
|
|
|
| 2584 |
const updateCartView = () => {
|
| 2585 |
cartItemsEl.innerHTML = '';
|
| 2586 |
let subtotal = 0;
|
| 2587 |
+
let totalQty = 0;
|
| 2588 |
if (Object.keys(cart).length === 0) {
|
| 2589 |
cartItemsEl.innerHTML = '<p class="text-center text-muted">Корзина пуста</p>';
|
| 2590 |
}
|
| 2591 |
for (const id in cart) {
|
| 2592 |
const item = cart[id];
|
| 2593 |
subtotal += (parseLocaleNumber(item.price) - parseLocaleNumber(item.discount)) * item.quantity;
|
| 2594 |
+
totalQty += parseInt(item.quantity, 10) || 0;
|
| 2595 |
|
| 2596 |
let nameField = '';
|
| 2597 |
let priceField = '';
|
|
|
|
| 2635 |
cartSubtotalEl.textContent = formatCurrencyJS(subtotal) + ' ₸';
|
| 2636 |
cartDeliveryEl.textContent = formatCurrencyJS(deliveryCost) + ' ₸';
|
| 2637 |
cartTotalEl.textContent = formatCurrencyJS(total) + ' ₸';
|
| 2638 |
+
if(cartTotalQtyEl) cartTotalQtyEl.textContent = totalQty + ' шт.';
|
| 2639 |
};
|
| 2640 |
|
| 2641 |
const addToCart = (product, variant, price, priceType) => {
|
|
|
|
| 2661 |
const container = document.getElementById('price-options-container');
|
| 2662 |
container.innerHTML = '';
|
| 2663 |
|
| 2664 |
+
const prices =[
|
| 2665 |
{ type: 'Общая', value: variant.price_regular || variant.price },
|
| 2666 |
{ type: 'Минимальная', value: variant.price_min },
|
| 2667 |
{ type: 'Оптовая', value: variant.price_wholesale }
|
|
|
|
| 2801 |
});
|
| 2802 |
}
|
| 2803 |
|
| 2804 |
+
document.getElementById('alphabet-filter')?.addEventListener('click', e => {
|
| 2805 |
+
if (e.target.classList.contains('filter-btn')) {
|
| 2806 |
+
document.querySelectorAll('.filter-btn').forEach(b => {
|
| 2807 |
+
b.classList.remove('active', 'btn-secondary');
|
| 2808 |
+
b.classList.add('btn-outline-secondary');
|
| 2809 |
+
});
|
| 2810 |
+
e.target.classList.remove('btn-outline-secondary');
|
| 2811 |
+
e.target.classList.add('active', 'btn-secondary');
|
| 2812 |
+
|
| 2813 |
+
const letter = e.target.dataset.letter;
|
| 2814 |
+
document.querySelectorAll('#product-grid .product-card').forEach(card => {
|
| 2815 |
+
if (letter === 'all' || card.dataset.letter === letter) {
|
| 2816 |
+
card.style.display = 'block';
|
| 2817 |
+
} else {
|
| 2818 |
+
card.style.display = 'none';
|
| 2819 |
+
}
|
| 2820 |
+
});
|
| 2821 |
+
|
| 2822 |
+
document.getElementById('product-search-results').style.display = 'none';
|
| 2823 |
+
document.getElementById('product-grid').style.display = 'grid';
|
| 2824 |
+
document.getElementById('product-search').value = '';
|
| 2825 |
+
}
|
| 2826 |
+
});
|
| 2827 |
+
|
| 2828 |
const productSearchInput = document.getElementById('product-search');
|
| 2829 |
+
const productGridEl = document.getElementById('product-grid');
|
| 2830 |
+
const alphabetFilterEl = document.getElementById('alphabet-filter');
|
| 2831 |
const productSearchResultsEl = document.getElementById('product-search-results');
|
| 2832 |
|
| 2833 |
productSearchInput.addEventListener('input', e => {
|
| 2834 |
const term = e.target.value.toLowerCase().trim();
|
| 2835 |
|
| 2836 |
if (term === '') {
|
| 2837 |
+
productGridEl.style.display = 'grid';
|
| 2838 |
+
alphabetFilterEl.style.display = 'flex';
|
| 2839 |
productSearchResultsEl.style.display = 'none';
|
| 2840 |
+
const activeFilter = document.querySelector('.filter-btn.active');
|
| 2841 |
+
if (activeFilter) activeFilter.click();
|
|
|
|
|
|
|
| 2842 |
return;
|
| 2843 |
}
|
| 2844 |
|
| 2845 |
+
productGridEl.style.display = 'none';
|
| 2846 |
+
alphabetFilterEl.style.display = 'none';
|
| 2847 |
productSearchResultsEl.style.display = 'grid';
|
| 2848 |
|
| 2849 |
const filtered = allProducts.filter(p => p.name.toLowerCase().includes(term) || p.barcode.toLowerCase().includes(term));
|
|
|
|
| 4479 |
<td class="text-end fw-bold">{{ format_currency_py(e.amount) }} ₸</td>
|
| 4480 |
<td class="text-end">
|
| 4481 |
<form action="{{ url_for('delete_personal_expense', expense_id=e.id) }}" method="POST" onsubmit="return confirm('Удалить этот расход?');">
|
| 4482 |
+
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1"><i class="fas fa-trash">
|
| 4483 |
+
</i></button>
|
| 4484 |
</form>
|
| 4485 |
</td>
|
| 4486 |
</tr>
|
|
|
|
| 4654 |
{% endif %}
|
| 4655 |
</td>
|
| 4656 |
<td>
|
| 4657 |
+
{% if t.type == 'sale' and t.status in['completed', 'partially_returned'] %}
|
| 4658 |
<a href="{{ url_for('return_transaction', transaction_id=t.id, cashier_id=user.id) }}" class="btn btn-sm btn-warning">Возврат</a>
|
| 4659 |
{% endif %}
|
| 4660 |
</td>
|
|
|
|
| 4759 |
</div>
|
| 4760 |
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
|
| 4761 |
</form>
|
| 4762 |
+
</div>
|
| 4763 |
</div>
|
| 4764 |
</div>
|
| 4765 |
"""
|