Kgshop commited on
Commit
56bbd47
·
verified ·
1 Parent(s): 79f265b

Upload Soola.txt

Browse files
Files changed (1) hide show
  1. Soola.txt +1193 -0
Soola.txt ADDED
@@ -0,0 +1,1193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory, session
3
+ import json
4
+ import os
5
+ import logging
6
+ import threading
7
+ import time
8
+ from datetime import datetime
9
+ import pytz
10
+ from huggingface_hub import HfApi, hf_hub_download
11
+ from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
12
+ import uuid
13
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
14
+ import functools
15
+
16
+ app = Flask(__name__)
17
+ app.secret_key = os.urandom(24)
18
+
19
+ INVENTORY_FILE = 'inventory.json'
20
+ SALES_FILE = 'sales.json'
21
+ USERS_FILE = 'users.json'
22
+ KASSA_FILE = 'kassa.json'
23
+
24
+ HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE")
25
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE")
26
+ REPO_ID = "Kgshop/postest"
27
+
28
+ BISHKEK_TZ = pytz.timezone('Asia/Bishkek')
29
+
30
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
31
+
32
+ data_locks = {
33
+ INVENTORY_FILE: threading.Lock(),
34
+ SALES_FILE: threading.Lock(),
35
+ USERS_FILE: threading.Lock(),
36
+ KASSA_FILE: threading.Lock()
37
+ }
38
+
39
+ def get_current_time():
40
+ return datetime.now(BISHKEK_TZ)
41
+
42
+ def hf_download_file(filename):
43
+ try:
44
+ hf_hub_download(
45
+ repo_id=REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN_READ,
46
+ local_dir=".", local_dir_use_symlinks=False, force_download=True,
47
+ )
48
+ logging.info(f"{filename} downloaded successfully from Hugging Face.")
49
+ except RepositoryNotFoundError:
50
+ logging.warning(f"Repository {REPO_ID} not found. Checking local {filename}.")
51
+ except HfHubHTTPError as e:
52
+ if e.response.status_code == 404:
53
+ logging.warning(f"File {filename} not found in repository {REPO_ID}. Will create a new one if it doesn't exist locally.")
54
+ else:
55
+ logging.error(f"HTTP error downloading {filename} from Hugging Face: {e}")
56
+ except Exception as e:
57
+ logging.error(f"Unknown error downloading {filename} from Hugging Face: {e}")
58
+
59
+ def load_generic_data(filename, default_structure_factory):
60
+ with data_locks[filename]:
61
+ hf_download_file(filename)
62
+ try:
63
+ with open(filename, 'r', encoding='utf-8') as file:
64
+ return json.load(file)
65
+ except (FileNotFoundError, json.JSONDecodeError):
66
+ logging.warning(f"{filename} not found or corrupted. Initializing with default structure.")
67
+ return default_structure_factory()
68
+
69
+ def save_generic_data(filename, data):
70
+ with data_locks[filename]:
71
+ try:
72
+ temp_file = filename + ".tmp"
73
+ with open(temp_file, 'w', encoding='utf-8') as file:
74
+ json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder)
75
+ os.replace(temp_file, filename)
76
+ logging.info(f"Data successfully saved to local file {filename}.")
77
+ except Exception as e:
78
+ logging.error(f"Critical error saving data to {filename}: {e}", exc_info=True)
79
+
80
+ def load_inventory(): return load_generic_data(INVENTORY_FILE, lambda: [])
81
+ def save_inventory(data): save_generic_data(INVENTORY_FILE, data)
82
+
83
+ def load_sales(): return load_generic_data(SALES_FILE, lambda: [])
84
+ def save_sales(data): save_generic_data(SALES_FILE, data)
85
+
86
+ def load_users(): return load_generic_data(USERS_FILE, lambda: [])
87
+ def save_users(data): save_generic_data(USERS_FILE, data)
88
+
89
+ def load_kassa(): return load_generic_data(KASSA_FILE, lambda: {"kassas": [], "transactions": []})
90
+ def save_kassa(data): save_generic_data(KASSA_FILE, data)
91
+
92
+
93
+ @functools.lru_cache(maxsize=1)
94
+ def get_hf_api():
95
+ if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE":
96
+ logging.warning("HF_TOKEN_WRITE not set. Uploads to Hugging Face will be disabled.")
97
+ return None
98
+ try: return HfApi()
99
+ except Exception as e: logging.error(f"Error initializing HfApi: {e}"); return None
100
+
101
+ def upload_to_hf(filepath):
102
+ api = get_hf_api()
103
+ if not api: return
104
+ if not os.path.exists(filepath): return
105
+ try:
106
+ filename = os.path.basename(filepath)
107
+ commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z')
108
+ api.upload_file(
109
+ path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset",
110
+ token=HF_TOKEN_WRITE, commit_message=f"Automated backup {filename} {commit_time}",
111
+ run_as_future=True
112
+ )
113
+ except Exception as e:
114
+ logging.error(f"Error initiating upload of {filepath} to Hugging Face: {e}")
115
+
116
+ def periodic_backup():
117
+ while True:
118
+ time.sleep(1800)
119
+ logging.info("Starting scheduled backup...")
120
+ for f in [INVENTORY_FILE, SALES_FILE, USERS_FILE, KASSA_FILE]:
121
+ upload_to_hf(f)
122
+
123
+ class DecimalEncoder(json.JSONEncoder):
124
+ def default(self, obj):
125
+ if isinstance(obj, Decimal): return str(obj)
126
+ return super().default(obj)
127
+
128
+ def to_decimal(value, default='0.00'):
129
+ if value is None or value == '': return Decimal(default)
130
+ try: return Decimal(str(value).replace(',', '.'))
131
+ except InvalidOperation: return Decimal(default)
132
+
133
+ def format_currency_py(value):
134
+ try:
135
+ num = to_decimal(value)
136
+ return f"{num:,.2f}".replace(",", " ").replace(".", ",")
137
+ except (InvalidOperation, TypeError, ValueError):
138
+ return "0,00"
139
+
140
+ def find_by_id(item_id, item_list):
141
+ return next((item for item in item_list if isinstance(item, dict) and item.get('id') == item_id), None)
142
+
143
+ @app.before_request
144
+ def check_auth():
145
+ if request.endpoint and request.endpoint not in ['login', 'static']:
146
+ if 'user_id' not in session or 'kassa_id' not in session:
147
+ return redirect(url_for('login'))
148
+ if 'user_id' in session:
149
+ users = load_users()
150
+ user = find_by_id(session['user_id'], users)
151
+ if not user:
152
+ session.clear()
153
+ flash("Ваша сессия истекла. Пожалуйста, войдите снова.", "warning")
154
+ return redirect(url_for('login'))
155
+
156
+ @app.route('/', methods=['GET', 'POST'])
157
+ def login():
158
+ if request.method == 'POST':
159
+ user_id = request.form.get('user_id')
160
+ kassa_id = request.form.get('kassa_id')
161
+ pin = request.form.get('pin')
162
+ users = load_users()
163
+ user = find_by_id(user_id, users)
164
+
165
+ if user and user.get('pin') == pin:
166
+ session['user_id'] = user_id
167
+ session['user_name'] = user.get('name')
168
+ session['kassa_id'] = kassa_id
169
+ kassa_data = load_kassa()
170
+ kassa = find_by_id(kassa_id, kassa_data.get('kassas', []))
171
+ session['kassa_name'] = kassa.get('name') if kassa else 'Неизвестная касса'
172
+ flash(f"Добро пожаловать, {session['user_name']}! Вы работаете на '{session['kassa_name']}'.", "success")
173
+ return redirect(url_for('pos'))
174
+ else:
175
+ flash("Неверный PIN-код.", "danger")
176
+ return redirect(url_for('login'))
177
+
178
+ if 'user_id' in session and 'kassa_id' in session:
179
+ return redirect(url_for('pos'))
180
+
181
+ users = [u for u in load_users() if u.get('is_active', True)]
182
+ kassa_data = load_kassa()
183
+ kassas = kassa_data.get('kassas', [])
184
+ html = BASE_TEMPLATE.replace('__TITLE__', "Вход").replace('__CONTENT__', LOGIN_CONTENT).replace('__SCRIPTS__', '')
185
+ return render_template_string(html, users=users, kassas=kassas, no_sidebar=True)
186
+
187
+ @app.route('/logout')
188
+ def logout():
189
+ session.clear()
190
+ flash("Вы успешно вышли из системы.", "success")
191
+ return redirect(url_for('login'))
192
+
193
+ @app.route('/pos')
194
+ def pos():
195
+ html = BASE_TEMPLATE.replace('__TITLE__', "Касса").replace('__CONTENT__', POS_CONTENT).replace('__SCRIPTS__', POS_SCRIPTS)
196
+ return render_template_string(html)
197
+
198
+ @app.route('/api/product_by_barcode/<barcode>')
199
+ def get_product_by_barcode(barcode):
200
+ inventory = load_inventory()
201
+ product = next((p for p in inventory if p.get('barcode') == barcode), None)
202
+ if product:
203
+ if to_decimal(product.get('quantity', '0')) > 0:
204
+ return jsonify(product)
205
+ else:
206
+ return jsonify({'error': 'Товар закончился'}), 404
207
+ return jsonify({'error': 'Товар не найден'}), 404
208
+
209
+ @app.route('/api/process_sale', methods=['POST'])
210
+ def process_sale():
211
+ data = request.json
212
+ cart = data.get('cart', [])
213
+ payment_method = data.get('payment_method')
214
+ user_id = session.get('user_id')
215
+ kassa_id = session.get('kassa_id')
216
+
217
+ if not all([cart, payment_method, user_id, kassa_id]):
218
+ return jsonify({'error': 'Неполные данные для продажи.'}), 400
219
+
220
+ inventory = load_inventory()
221
+ sales = load_sales()
222
+ kassa_data = load_kassa()
223
+ users = load_users()
224
+
225
+ user = find_by_id(user_id, users)
226
+ kassa = find_by_id(kassa_id, kassa_data.get('kassas', []))
227
+
228
+ if not user or not kassa:
229
+ return jsonify({'error': 'Пользователь или касса не найдены.'}), 400
230
+
231
+ sale_id = uuid.uuid4().hex
232
+ total_amount = Decimal('0.00')
233
+ sale_items = []
234
+ inventory_updates = {}
235
+
236
+ for item in cart:
237
+ product_id = item.get('id')
238
+ quantity_sold = to_decimal(item.get('quantity', '0'))
239
+
240
+ prod_in_inv = find_by_id(product_id, inventory)
241
+ if not prod_in_inv:
242
+ return jsonify({'error': f"Товар '{item.get('name')}' не найден в инвентаре."}), 400
243
+
244
+ current_stock = to_decimal(prod_in_inv.get('quantity', '0'))
245
+ if quantity_sold > current_stock:
246
+ return jsonify({'error': f"Недостаточно товара '{item.get('name')}'. В наличии: {current_stock}"}), 400
247
+
248
+ sale_price = to_decimal(prod_in_inv.get('sale_price', '0'))
249
+ item_total = sale_price * quantity_sold
250
+ total_amount += item_total
251
+
252
+ sale_items.append({
253
+ 'product_id': product_id,
254
+ 'name': prod_in_inv.get('name'),
255
+ 'quantity': str(quantity_sold),
256
+ 'sale_price': str(sale_price),
257
+ 'total': str(item_total)
258
+ })
259
+ inventory_updates[product_id] = current_stock - quantity_sold
260
+
261
+ timestamp = get_current_time().isoformat()
262
+
263
+ new_sale = {
264
+ 'id': sale_id,
265
+ 'user_id': user_id,
266
+ 'user_name': user.get('name'),
267
+ 'kassa_id': kassa_id,
268
+ 'kassa_name': kassa.get('name'),
269
+ 'items': sale_items,
270
+ 'total_amount': str(total_amount),
271
+ 'payment_method': payment_method,
272
+ 'timestamp': timestamp
273
+ }
274
+ sales.append(new_sale)
275
+
276
+ for prod_id, new_quantity in inventory_updates.items():
277
+ for i, prod in enumerate(inventory):
278
+ if prod.get('id') == prod_id:
279
+ inventory[i]['quantity'] = str(new_quantity)
280
+ inventory[i]['timestamp_updated'] = timestamp
281
+ break
282
+
283
+ if payment_method == 'cash':
284
+ for i, k in enumerate(kassa_data['kassas']):
285
+ if k.get('id') == kassa_id:
286
+ kassa_data['kassas'][i]['balance'] = str(to_decimal(k.get('balance', '0')) + total_amount)
287
+ break
288
+
289
+ kassa_data['transactions'].append({
290
+ 'id': uuid.uuid4().hex,
291
+ 'kassa_id': kassa_id,
292
+ 'type': 'sale',
293
+ 'amount': str(total_amount),
294
+ 'description': f"Продажа #{sale_id[:8]}",
295
+ 'user_id': user_id,
296
+ 'timestamp': timestamp
297
+ })
298
+
299
+ save_inventory(inventory)
300
+ save_sales(sales)
301
+ save_kassa(kassa_data)
302
+
303
+ upload_to_hf(INVENTORY_FILE)
304
+ upload_to_hf(SALES_FILE)
305
+ upload_to_hf(KASSA_FILE)
306
+
307
+ return jsonify({'success': True, 'sale_id': sale_id, 'message': 'Продажа успешно завершена!'})
308
+
309
+
310
+ @app.route('/inventory', methods=['GET', 'POST'])
311
+ def inventory_page():
312
+ inventory = load_inventory()
313
+ if request.method == 'POST':
314
+ action = request.form.get('action', 'add')
315
+ product_id = request.form.get('product_id')
316
+
317
+ name = request.form.get('name', '').strip()
318
+ barcode = request.form.get('barcode', '').strip()
319
+ quantity = to_decimal(request.form.get('quantity', '0'))
320
+ purchase_price = to_decimal(request.form.get('purchase_price', '0'))
321
+ sale_price = to_decimal(request.form.get('sale_price', '0'))
322
+ category = request.form.get('category', 'Без категории').strip()
323
+
324
+ if not name or not barcode or not sale_price > 0:
325
+ flash("Название, штрих-код и цена продажи обязательны.", "danger")
326
+ return redirect(url_for('inventory_page'))
327
+
328
+ timestamp = get_current_time().isoformat()
329
+
330
+ if action == 'edit' and product_id:
331
+ product = find_by_id(product_id, inventory)
332
+ if product:
333
+ product.update({
334
+ 'name': name, 'barcode': barcode, 'quantity': str(quantity),
335
+ 'purchase_price': str(purchase_price), 'sale_price': str(sale_price),
336
+ 'category': category, 'timestamp_updated': timestamp
337
+ })
338
+ flash(f"Товар '{name}' обновлен.", "success")
339
+ else:
340
+ if any(p.get('barcode') == barcode for p in inventory):
341
+ flash(f"Товар со штрих-кодом {barcode} уже существует.", "warning")
342
+ return redirect(url_for('inventory_page'))
343
+
344
+ new_product = {
345
+ 'id': uuid.uuid4().hex, 'name': name, 'barcode': barcode, 'quantity': str(quantity),
346
+ 'purchase_price': str(purchase_price), 'sale_price': str(sale_price),
347
+ 'category': category, 'timestamp_added': timestamp, 'timestamp_updated': timestamp
348
+ }
349
+ inventory.append(new_product)
350
+ flash(f"Товар '{name}' добавлен.", "success")
351
+
352
+ save_inventory(inventory)
353
+ upload_to_hf(INVENTORY_FILE)
354
+ return redirect(url_for('inventory_page'))
355
+
356
+ inventory.sort(key=lambda x: x.get('name', '').lower())
357
+ categories = sorted(list(set(p.get('category', 'Без категории') for p in inventory)))
358
+ html = BASE_TEMPLATE.replace('__TITLE__', "Инвентарь").replace('__CONTENT__', INVENTORY_CONTENT).replace('__SCRIPTS__', INVENTORY_SCRIPTS)
359
+ return render_template_string(html, inventory=inventory, categories=categories)
360
+
361
+
362
+ @app.route('/inventory/delete/<product_id>', methods=['POST'])
363
+ def delete_product(product_id):
364
+ inventory = load_inventory()
365
+ initial_len = len(inventory)
366
+ inventory = [p for p in inventory if p.get('id') != product_id]
367
+ if len(inventory) < initial_len:
368
+ save_inventory(inventory)
369
+ upload_to_hf(INVENTORY_FILE)
370
+ flash("Товар удален.", "success")
371
+ else:
372
+ flash("Товар не найден.", "warning")
373
+ return redirect(url_for('inventory_page'))
374
+
375
+
376
+ @app.route('/kassa', methods=['GET', 'POST'])
377
+ def kassa_page():
378
+ kassa_data = load_kassa()
379
+ if request.method == 'POST':
380
+ action = request.form.get('action')
381
+ timestamp = get_current_time().isoformat()
382
+
383
+ if action == 'add_kassa':
384
+ name = request.form.get('name', '').strip()
385
+ balance = to_decimal(request.form.get('balance', '0'))
386
+ if name:
387
+ new_kassa = {'id': uuid.uuid4().hex, 'name': name, 'balance': str(balance)}
388
+ kassa_data['kassas'].append(new_kassa)
389
+ flash(f"Касса '{name}' добавлена.", "success")
390
+ else:
391
+ flash("Название кассы обязательно.", "danger")
392
+
393
+ elif action in ['income', 'expense']:
394
+ kassa_id = request.form.get('kassa_id')
395
+ amount = to_decimal(request.form.get('amount', '0'))
396
+ description = request.form.get('description', '').strip()
397
+
398
+ if kassa_id and amount > 0 and description:
399
+ kassa = find_by_id(kassa_id, kassa_data['kassas'])
400
+ if kassa:
401
+ current_balance = to_decimal(kassa.get('balance', '0'))
402
+ new_balance = current_balance + amount if action == 'income' else current_balance - amount
403
+ kassa['balance'] = str(new_balance)
404
+
405
+ kassa_data['transactions'].append({
406
+ 'id': uuid.uuid4().hex, 'kassa_id': kassa_id, 'type': action,
407
+ 'amount': str(amount), 'description': description,
408
+ 'user_id': session.get('user_id'), 'timestamp': timestamp
409
+ })
410
+ flash(f"Операция '{description}' на сумму {format_currency_py(amount)} проведена.", "success")
411
+ else:
412
+ flash("Касса не найдена.", "danger")
413
+ else:
414
+ flash("Заполните все поля для транзакции.", "danger")
415
+
416
+ save_kassa(kassa_data)
417
+ upload_to_hf(KASSA_FILE)
418
+ return redirect(url_for('kassa_page'))
419
+
420
+ users = load_users()
421
+ for t in kassa_data['transactions']:
422
+ kassa = find_by_id(t.get('kassa_id'), kassa_data['kassas'])
423
+ t['kassa_name'] = kassa.get('name') if kassa else 'N/A'
424
+ user = find_by_id(t.get('user_id'), users)
425
+ t['user_name'] = user.get('name') if user else 'Система'
426
+
427
+ kassa_data['transactions'].sort(key=lambda x: x.get('timestamp', ''), reverse=True)
428
+ html = BASE_TEMPLATE.replace('__TITLE__', "Кассы").replace('__CONTENT__', KASSA_CONTENT).replace('__SCRIPTS__', '')
429
+ return render_template_string(html, kassa_data=kassa_data)
430
+
431
+
432
+ @app.route('/users', methods=['GET', 'POST'])
433
+ def users_page():
434
+ users = load_users()
435
+ if request.method == 'POST':
436
+ action = request.form.get('action', 'add')
437
+ user_id = request.form.get('user_id')
438
+ name = request.form.get('name', '').strip()
439
+ pin = request.form.get('pin', '').strip()
440
+
441
+ if not name or not pin or not pin.isdigit() or len(pin) != 4:
442
+ flash("Имя и 4-значный PIN-код обязательны.", "danger")
443
+ return redirect(url_for('users_page'))
444
+
445
+ if action == 'edit' and user_id:
446
+ user = find_by_id(user_id, users)
447
+ if user:
448
+ user.update({'name': name, 'pin': pin})
449
+ flash(f"Данные пользователя '{name}' обновлены.", "success")
450
+ else:
451
+ new_user = {
452
+ 'id': uuid.uuid4().hex, 'name': name, 'pin': pin,
453
+ 'role': 'cashier', 'is_active': True
454
+ }
455
+ users.append(new_user)
456
+ flash(f"Пользователь '{name}' добавлен.", "success")
457
+
458
+ save_users(users)
459
+ upload_to_hf(USERS_FILE)
460
+ return redirect(url_for('users_page'))
461
+
462
+ html = BASE_TEMPLATE.replace('__TITLE__', "Пользователи").replace('__CONTENT__', USERS_CONTENT).replace('__SCRIPTS__', USERS_SCRIPTS)
463
+ return render_template_string(html, users=users)
464
+
465
+ @app.route('/users/toggle_active/<user_id>', methods=['POST'])
466
+ def toggle_user_active(user_id):
467
+ users = load_users()
468
+ user = find_by_id(user_id, users)
469
+ if user:
470
+ user['is_active'] = not user.get('is_active', True)
471
+ status = "активирован" if user['is_active'] else "деактивирован"
472
+ save_users(users)
473
+ upload_to_hf(USERS_FILE)
474
+ flash(f"Пользователь '{user.get('name')}' {status}.", "success")
475
+ else:
476
+ flash("Пользователь не найден.", "warning")
477
+ return redirect(url_for('users_page'))
478
+
479
+ @app.route('/admin')
480
+ def admin_panel():
481
+ inventory = load_inventory()
482
+ sales = load_sales()
483
+ kassa_data = load_kassa()
484
+
485
+ total_products = len(inventory)
486
+ total_stock_value_purchase = sum(to_decimal(p.get('quantity', 0)) * to_decimal(p.get('purchase_price', 0)) for p in inventory)
487
+ total_stock_value_sale = sum(to_decimal(p.get('quantity', 0)) * to_decimal(p.get('sale_price', 0)) for p in inventory)
488
+ total_cash_in_kassas = sum(to_decimal(k.get('balance', 0)) for k in kassa_data.get('kassas', []))
489
+
490
+ today_start = get_current_time().replace(hour=0, minute=0, second=0, microsecond=0)
491
+ sales_today = [s for s in sales if datetime.fromisoformat(s['timestamp']) >= today_start]
492
+ sales_today_count = len(sales_today)
493
+ sales_today_revenue = sum(to_decimal(s.get('total_amount', 0)) for s in sales_today)
494
+
495
+ stats = {
496
+ 'total_products': total_products,
497
+ 'total_stock_value_purchase': total_stock_value_purchase,
498
+ 'total_stock_value_sale': total_stock_value_sale,
499
+ 'total_cash_in_kassas': total_cash_in_kassas,
500
+ 'sales_today_count': sales_today_count,
501
+ 'sales_today_revenue': sales_today_revenue,
502
+ }
503
+
504
+ html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', '')
505
+ return render_template_string(html, stats=stats)
506
+
507
+
508
+ @app.route('/backup', methods=['POST'])
509
+ def backup_hf():
510
+ for f in [INVENTORY_FILE, SALES_FILE, USERS_FILE, KASSA_FILE]:
511
+ upload_to_hf(f)
512
+ flash("Запущена загрузка резервных копий в облако.", "success")
513
+ return redirect(url_for('admin_panel'))
514
+
515
+ @app.route('/download', methods=['GET'])
516
+ def download_hf():
517
+ for f in [INVENTORY_FILE, SALES_FILE, USERS_FILE, KASSA_FILE]:
518
+ hf_download_file(f)
519
+ flash("Данные загружены из облака. Локальные файлы перезаписаны.", "success")
520
+ return redirect(url_for('admin_panel'))
521
+
522
+
523
+ LOGIN_CONTENT = """
524
+ <div class="container">
525
+ <div class="row justify-content-center align-items-center" style="min-height: 100vh;">
526
+ <div class="col-md-6 col-lg-4">
527
+ <div class="card shadow-sm">
528
+ <div class="card-body p-4 p-md-5">
529
+ <div class="text-center mb-4">
530
+ <i class="fas fa-cash-register fa-3x text-primary"></i>
531
+ <h3 class="mt-3">Вход в систему</h3>
532
+ </div>
533
+ {% with messages = get_flashed_messages(with_categories=true) %}
534
+ {% if messages %}
535
+ {% for category, message in messages %}
536
+ <div class="alert alert-{{ category }}">{{ message }}</div>
537
+ {% endfor %}
538
+ {% endif %}
539
+ {% endwith %}
540
+ <form method="POST" action="{{ url_for('login') }}">
541
+ <div class="mb-3">
542
+ <label for="user_id" class="form-label">Кассир</label>
543
+ <select id="user_id" name="user_id" class="form-select form-select-lg" required>
544
+ <option value="" disabled selected>-- Выберите себя --</option>
545
+ {% for user in users %}
546
+ <option value="{{ user.id }}">{{ user.name }}</option>
547
+ {% endfor %}
548
+ </select>
549
+ </div>
550
+ <div class="mb-3">
551
+ <label for="kassa_id" class="form-label">Касса</label>
552
+ <select id="kassa_id" name="kassa_id" class="form-select form-select-lg" required>
553
+ <option value="" disabled selected>-- Выберите кассу --</option>
554
+ {% for kassa in kassas %}
555
+ <option value="{{ kassa.id }}">{{ kassa.name }}</option>
556
+ {% endfor %}
557
+ </select>
558
+ </div>
559
+ <div class="mb-4">
560
+ <label for="pin" class="form-label">PIN-код</label>
561
+ <input type="password" id="pin" name="pin" class="form-control form-control-lg" required inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off">
562
+ </div>
563
+ <div class="d-grid">
564
+ <button type="submit" class="btn btn-primary btn-lg">Войти</button>
565
+ </div>
566
+ </form>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ </div>
571
+ </div>
572
+ """
573
+
574
+ POS_CONTENT = """
575
+ <div class="row" style="height: calc(100vh - var(--topbar-height) - 3rem);">
576
+ <div class="col-md-7 h-100 d-flex flex-column">
577
+ <div class="card flex-grow-1">
578
+ <div class="card-header d-flex justify-content-between align-items-center">
579
+ <h5 class="mb-0">Чек</h5>
580
+ <button id="clear-cart-btn" class="btn btn-sm btn-outline-danger"><i class="fas fa-times me-1"></i>Очистить</button>
581
+ </div>
582
+ <div class="card-body p-0" style="overflow-y: auto;">
583
+ <table class="table table-hover table-striped mb-0" id="cart-table">
584
+ <thead class="table-light sticky-top">
585
+ <tr><th>Название</th><th style="width: 120px;">Кол-во</th><th class="text-end">Цена</th><th class="text-end">Сумма</th><th class="text-center" style="width: 50px;"></th></tr>
586
+ </thead>
587
+ <tbody></tbody>
588
+ </table>
589
+ </div>
590
+ <div class="card-footer fs-4 fw-bold d-flex justify-content-between">
591
+ <span>Итого:</span>
592
+ <span id="cart-total">0,00 сом</span>
593
+ </div>
594
+ </div>
595
+ </div>
596
+ <div class="col-md-5 h-100 d-flex flex-column">
597
+ <div class="card mb-3">
598
+ <div class="card-body">
599
+ <div id="scanner-container" style="width: 100%;"></div>
600
+ <div class="text-center mt-2">
601
+ <button id="start-scan-btn" class="btn btn-info"><i class="fas fa-barcode me-2"></i>Сканировать</button>
602
+ <button id="stop-scan-btn" class="btn btn-secondary" style="display:none;"><i class="fas fa-stop me-2"></i>Стоп</button>
603
+ </div>
604
+ <div id="scan-result" class="mt-2 text-center"></div>
605
+ </div>
606
+ </div>
607
+ <div class="card flex-grow-1">
608
+ <div class="card-body d-flex flex-column justify-content-end">
609
+ <div class="d-grid gap-3">
610
+ <button class="btn btn-success btn-lg" data-payment="cash">Наличные</button>
611
+ <button class="btn btn-primary btn-lg" data-payment="card">Карта</button>
612
+ </div>
613
+ </div>
614
+ </div>
615
+ </div>
616
+ </div>
617
+ """
618
+
619
+ POS_SCRIPTS = """
620
+ <script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
621
+ <script>
622
+ document.addEventListener('DOMContentLoaded', function () {
623
+ const cart = [];
624
+ const cartTableBody = document.querySelector('#cart-table tbody');
625
+ const cartTotalEl = document.getElementById('cart-total');
626
+ let html5QrCode;
627
+
628
+ function renderCart() {
629
+ cartTableBody.innerHTML = '';
630
+ let total = 0;
631
+ cart.forEach((item, index) => {
632
+ const itemTotal = parseFloat(item.sale_price) * item.quantity;
633
+ total += itemTotal;
634
+ const row = `
635
+ <tr>
636
+ <td>${item.name}</td>
637
+ <td>
638
+ <div class="input-group input-group-sm">
639
+ <button class="btn btn-outline-secondary btn-minus" data-index="${index}" type="button">-</button>
640
+ <input type="number" class="form-control text-center quantity-input" value="${item.quantity}" data-index="${index}" min="1" step="1">
641
+ <button class="btn btn-outline-secondary btn-plus" data-index="${index}" type="button">+</button>
642
+ </div>
643
+ </td>
644
+ <td class="text-end">${formatCurrencyJS(item.sale_price)}</td>
645
+ <td class="text-end">${formatCurrencyJS(itemTotal)}</td>
646
+ <td class="text-center"><button class="btn btn-sm btn-outline-danger btn-remove" data-index="${index}">&times;</button></td>
647
+ </tr>
648
+ `;
649
+ cartTableBody.insertAdjacentHTML('beforeend', row);
650
+ });
651
+ cartTotalEl.textContent = formatCurrencyJS(total) + ' сом';
652
+ }
653
+
654
+ function addToCart(product) {
655
+ const existingItem = cart.find(item => item.id === product.id);
656
+ if (existingItem) {
657
+ existingItem.quantity++;
658
+ } else {
659
+ cart.push({ ...product, quantity: 1 });
660
+ }
661
+ renderCart();
662
+ }
663
+
664
+ function updateQuantity(index, newQuantity) {
665
+ if (newQuantity > 0) {
666
+ cart[index].quantity = newQuantity;
667
+ } else {
668
+ cart.splice(index, 1);
669
+ }
670
+ renderCart();
671
+ }
672
+
673
+ cartTableBody.addEventListener('click', e => {
674
+ const index = e.target.dataset.index;
675
+ if (e.target.classList.contains('btn-plus')) {
676
+ cart[index].quantity++;
677
+ renderCart();
678
+ } else if (e.target.classList.contains('btn-minus')) {
679
+ if (cart[index].quantity > 1) {
680
+ cart[index].quantity--;
681
+ } else {
682
+ cart.splice(index, 1);
683
+ }
684
+ renderCart();
685
+ } else if (e.target.classList.contains('btn-remove')) {
686
+ cart.splice(index, 1);
687
+ renderCart();
688
+ }
689
+ });
690
+
691
+ cartTableBody.addEventListener('change', e => {
692
+ if (e.target.classList.contains('quantity-input')) {
693
+ const index = e.target.dataset.index;
694
+ const newQuantity = parseInt(e.target.value, 10);
695
+ if (!isNaN(newQuantity)) {
696
+ updateQuantity(index, newQuantity);
697
+ }
698
+ }
699
+ });
700
+
701
+ document.getElementById('clear-cart-btn').addEventListener('click', () => {
702
+ cart.length = 0;
703
+ renderCart();
704
+ });
705
+
706
+ const onScanSuccess = (decodedText, decodedResult) => {
707
+ const scanResultEl = document.getElementById('scan-result');
708
+ scanResultEl.innerHTML = `<span class="text-success">Скан: ${decodedText}</span>`;
709
+ fetch(`/api/product_by_barcode/${decodedText}`)
710
+ .then(response => {
711
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
712
+ return response.json();
713
+ })
714
+ .then(product => {
715
+ if (product.error) {
716
+ scanResultEl.innerHTML = `<span class="text-danger">${product.error}</span>`;
717
+ } else {
718
+ addToCart(product);
719
+ }
720
+ })
721
+ .catch(e => {
722
+ scanResultEl.innerHTML = `<span class="text-danger">Товар не найден или ошибка сети</span>`;
723
+ console.error(e);
724
+ });
725
+ };
726
+
727
+ const onScanFailure = (error) => { /* console.warn(`Code scan error = ${error}`); */ };
728
+
729
+ const startScanBtn = document.getElementById('start-scan-btn');
730
+ const stopScanBtn = document.getElementById('stop-scan-btn');
731
+
732
+ startScanBtn.addEventListener('click', () => {
733
+ html5QrCode = new Html5Qrcode("scanner-container");
734
+ const config = { fps: 10, qrbox: { width: 250, height: 250 } };
735
+ html5QrCode.start({ facingMode: "environment" }, config, onScanSuccess, onScanFailure);
736
+ startScanBtn.style.display = 'none';
737
+ stopScanBtn.style.display = 'inline-block';
738
+ });
739
+
740
+ stopScanBtn.addEventListener('click', () => {
741
+ html5QrCode.stop().then(() => {
742
+ startScanBtn.style.display = 'inline-block';
743
+ stopScanBtn.style.display = 'none';
744
+ document.getElementById('scanner-container').innerHTML = '';
745
+ }).catch(err => console.error("Failed to stop scanner", err));
746
+ });
747
+
748
+ document.querySelectorAll('[data-payment]').forEach(button => {
749
+ button.addEventListener('click', function() {
750
+ if (cart.length === 0) {
751
+ alert('Чек пуст.');
752
+ return;
753
+ }
754
+ const paymentMethod = this.dataset.payment;
755
+
756
+ fetch('/api/process_sale', {
757
+ method: 'POST',
758
+ headers: {'Content-Type': 'application/json'},
759
+ body: JSON.stringify({ cart: cart, payment_method: paymentMethod })
760
+ })
761
+ .then(response => response.json())
762
+ .then(data => {
763
+ if (data.success) {
764
+ alert(data.message);
765
+ cart.length = 0;
766
+ renderCart();
767
+ } else {
768
+ alert('Ошибка: ' + data.error);
769
+ }
770
+ })
771
+ .catch(error => alert('Сетевая ошибка при завершении продажи.'));
772
+ });
773
+ });
774
+ });
775
+ </script>
776
+ """
777
+
778
+ INVENTORY_CONTENT = """
779
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
780
+ <h1 class="h2">Инвентарь</h1>
781
+ <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#productModal" onclick="prepareAddModal()"><i class="fas fa-plus me-2"></i>Добавить товар</button>
782
+ </div>
783
+ <div class="card">
784
+ <div class="card-body">
785
+ <div class="mb-3"><input type="text" id="inventory-search" class="form-control" placeholder="Поиск по названию, штрих-коду, категории..."></div>
786
+ <div class="table-responsive">
787
+ <table class="table table-hover table-bordered" id="inventory-table">
788
+ <thead class="table-light">
789
+ <tr><th>Название</th><th>Штрих-код</th><th>Категория</th><th class="text-end">Кол-во</th><th class="text-end">Цена закупки</th><th class="text-end">Цена продажи</th><th>Действия</th></tr>
790
+ </thead>
791
+ <tbody>
792
+ {% for p in inventory %}
793
+ <tr class="product-row" data-search="{{ p.name|lower }} {{ p.barcode }} {{ p.category|lower }}">
794
+ <td>{{ p.name }}</td><td>{{ p.barcode }}</td><td>{{ p.category }}</td>
795
+ <td class="text-end">{{ p.quantity }}</td><td class="text-end">{{ format_currency_py(p.purchase_price) }}</td><td class="text-end">{{ format_currency_py(p.sale_price) }}</td>
796
+ <td>
797
+ <button class="btn btn-sm btn-outline-primary" onclick="prepareEditModal('{{ p|tojson|safe }}')"><i class="fas fa-edit"></i></button>
798
+ <form method="POST" action="{{ url_for('delete_product', product_id=p.id) }}" class="d-inline" onsubmit="return confirm('Удалить товар?');">
799
+ <button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
800
+ </form>
801
+ </td>
802
+ </tr>
803
+ {% endfor %}
804
+ </tbody>
805
+ </table>
806
+ </div>
807
+ </div>
808
+ </div>
809
+ <div class="modal fade" id="productModal" tabindex="-1">
810
+ <div class="modal-dialog modal-lg">
811
+ <div class="modal-content">
812
+ <div class="modal-header">
813
+ <h5 class="modal-title" id="productModalLabel">Добавить товар</h5>
814
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
815
+ </div>
816
+ <form method="POST">
817
+ <input type="hidden" name="action" id="form_action" value="add">
818
+ <input type="hidden" name="product_id" id="form_product_id">
819
+ <div class="modal-body">
820
+ <div class="row">
821
+ <div class="col-md-6 mb-3"><label class="form-label">Название</label><input type="text" name="name" id="form_name" class="form-control" required></div>
822
+ <div class="col-md-6 mb-3"><label class="form-label">Штрих-код</label>
823
+ <div class="input-group">
824
+ <input type="text" name="barcode" id="form_barcode" class="form-control" required>
825
+ <button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#scannerModal"><i class="fas fa-barcode"></i></button>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ <div class="row">
830
+ <div class="col-md-4 mb-3"><label class="form-label">Количество</label><input type="number" name="quantity" id="form_quantity" class="form-control" value="0" step="1"></div>
831
+ <div class="col-md-4 mb-3"><label class="form-label">Цена закупки</label><input type="text" name="purchase_price" id="form_purchase_price" class="form-control" inputmode="decimal"></div>
832
+ <div class="col-md-4 mb-3"><label class="form-label">Цена продажи</label><input type="text" name="sale_price" id="form_sale_price" class="form-control" inputmode="decimal" required></div>
833
+ </div>
834
+ <div class="mb-3"><label class="form-label">Категория</label><input type="text" name="category" id="form_category" class="form-control" list="category-list"><datalist id="category-list">{% for c in categories %}<option value="{{ c }}">{% endfor %}</datalist></div>
835
+ </div>
836
+ <div class="modal-footer">
837
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
838
+ <button type="submit" class="btn btn-primary">Сохранить</button>
839
+ </div>
840
+ </form>
841
+ </div>
842
+ </div>
843
+ </div>
844
+ <div class="modal fade" id="scannerModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
845
+ <div class="modal-header"><h5 class="modal-title">Сканировать штрих-код</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
846
+ <div class="modal-body"><div id="modal-scanner-container" style="width: 100%;"></div></div>
847
+ </div></div></div>
848
+ """
849
+
850
+ INVENTORY_SCRIPTS = """
851
+ <script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
852
+ <script>
853
+ let modalScanner;
854
+ const productModal = new bootstrap.Modal(document.getElementById('productModal'));
855
+ const scannerModal = new bootstrap.Modal(document.getElementById('scannerModal'));
856
+
857
+ function prepareAddModal() {
858
+ document.getElementById('productModalLabel').textContent = 'Добавить товар';
859
+ document.getElementById('form_action').value = 'add';
860
+ const form = document.querySelector('#productModal form');
861
+ form.reset();
862
+ document.getElementById('form_product_id').value = '';
863
+ }
864
+
865
+ function prepareEditModal(productJson) {
866
+ const product = JSON.parse(productJson);
867
+ document.getElementById('productModalLabel').textContent = 'Редактировать товар';
868
+ document.getElementById('form_action').value = 'edit';
869
+ document.getElementById('form_product_id').value = product.id;
870
+ document.getElementById('form_name').value = product.name;
871
+ document.getElementById('form_barcode').value = product.barcode;
872
+ document.getElementById('form_quantity').value = product.quantity;
873
+ document.getElementById('form_purchase_price').value = (product.purchase_price || '').replace('.', ',');
874
+ document.getElementById('form_sale_price').value = (product.sale_price || '').replace('.', ',');
875
+ document.getElementById('form_category').value = product.category;
876
+ productModal.show();
877
+ }
878
+
879
+ document.getElementById('scannerModal').addEventListener('shown.bs.modal', () => {
880
+ modalScanner = new Html5Qrcode("modal-scanner-container");
881
+ const config = { fps: 10, qrbox: { width: 250, height: 150 } };
882
+ modalScanner.start({ facingMode: "environment" }, config,
883
+ (decodedText) => {
884
+ document.getElementById('form_barcode').value = decodedText;
885
+ modalScanner.stop();
886
+ scannerModal.hide();
887
+ },
888
+ (error) => {}
889
+ );
890
+ });
891
+
892
+ document.getElementById('scannerModal').addEventListener('hidden.bs.modal', () => {
893
+ if (modalScanner && modalScanner.isScanning) {
894
+ modalScanner.stop();
895
+ }
896
+ });
897
+
898
+ document.addEventListener('DOMContentLoaded', () => {
899
+ const searchInput = document.getElementById('inventory-search');
900
+ const tableBody = document.getElementById('inventory-table')?.querySelector('tbody');
901
+ if (searchInput && tableBody) {
902
+ searchInput.addEventListener('input', function() {
903
+ const searchTerm = this.value.toLowerCase().trim();
904
+ tableBody.querySelectorAll('tr.product-row').forEach(row => {
905
+ row.style.display = (row.dataset.search || '').includes(searchTerm) ? '' : 'none';
906
+ });
907
+ });
908
+ }
909
+ });
910
+ </script>
911
+ """
912
+
913
+ KASSA_CONTENT = """
914
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
915
+ <h1 class="h2">Кассы</h1>
916
+ </div>
917
+ <div class="row">
918
+ <div class="col-lg-4 mb-4">
919
+ <div class="card">
920
+ <div class="card-header"><h5 class="mb-0">Операции</h5></div>
921
+ <div class="card-body">
922
+ <form method="POST">
923
+ <input type="hidden" name="action" id="transaction_action" value="income">
924
+ <div class="mb-3">
925
+ <label class="form-label">Касса</label>
926
+ <select name="kassa_id" class="form-select" required>{% for k in kassa_data.kassas %}<option value="{{ k.id }}">{{ k.name }}</option>{% endfor %}</select>
927
+ </div>
928
+ <div class="mb-3">
929
+ <label class="form-label">Сумма</label>
930
+ <input type="text" name="amount" class="form-control" inputmode="decimal" required>
931
+ </div>
932
+ <div class="mb-3">
933
+ <label class="form-label">Описание</label>
934
+ <input type="text" name="description" class="form-control" required>
935
+ </div>
936
+ <div class="d-grid gap-2">
937
+ <button type="submit" class="btn btn-success" onclick="document.getElementById('transaction_action').value='income'">Внесение</button>
938
+ <button type="submit" class="btn btn-danger" onclick="document.getElementById('transaction_action').value='expense'">Изъятие</button>
939
+ </div>
940
+ </form>
941
+ </div>
942
+ </div>
943
+ <div class="card mt-4">
944
+ <div class="card-header"><h5 class="mb-0">Добавить кассу</h5></div>
945
+ <div class="card-body">
946
+ <form method="POST">
947
+ <input type="hidden" name="action" value="add_kassa">
948
+ <div class="mb-2"><label class="form-label">Название</label><input type="text" name="name" class="form-control" required></div>
949
+ <div class="mb-3"><label class="form-label">Начальный баланс</label><input type="text" name="balance" class="form-control" value="0" inputmode="decimal"></div>
950
+ <button type="submit" class="btn btn-primary">Добавить</button>
951
+ </form>
952
+ </div>
953
+ </div>
954
+ </div>
955
+ <div class="col-lg-8">
956
+ <div class="card mb-4">
957
+ <div class="card-header"><h5 class="mb-0">Текущие балансы</h5></div>
958
+ <ul class="list-group list-group-flush">
959
+ {% for k in kassa_data.kassas %}
960
+ <li class="list-group-item d-flex justify-content-between align-items-center">
961
+ {{ k.name }} <span class="badge bg-primary rounded-pill fs-6">{{ format_currency_py(k.balance) }} сом</span>
962
+ </li>
963
+ {% else %}
964
+ <li class="list-group-item">Кассы не добавлены.</li>
965
+ {% endfor %}
966
+ </ul>
967
+ </div>
968
+ <div class="card">
969
+ <div class="card-header"><h5 class="mb-0">История транзакций</h5></div>
970
+ <div class="card-body">
971
+ <div class="table-responsive" style="max-height: 400px;">
972
+ <table class="table table-sm table-hover">
973
+ <thead><tr><th>Дата</th><th>Касса</th><th>Тип</th><th>Сумма</th><th>Описание</th><th>Кассир</th></tr></thead>
974
+ <tbody>
975
+ {% for t in kassa_data.transactions %}
976
+ <tr>
977
+ <td>{{ t.timestamp[:16]|replace('T', ' ') }}</td><td>{{ t.kassa_name }}</td>
978
+ <td><span class="badge text-bg-{{ 'success' if t.type in ['income', 'sale'] else 'danger' }}">{{ t.type }}</span></td>
979
+ <td>{{ format_currency_py(t.amount) }}</td><td>{{ t.description }}</td><td>{{ t.user_name }}</td>
980
+ </tr>
981
+ {% endfor %}
982
+ </tbody>
983
+ </table>
984
+ </div>
985
+ </div>
986
+ </div>
987
+ </div>
988
+ </div>
989
+ """
990
+
991
+ USERS_CONTENT = """
992
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
993
+ <h1 class="h2">Пользователи</h1>
994
+ <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal" onclick="prepareAddUserModal()"><i class="fas fa-user-plus me-2"></i>Добавить</button>
995
+ </div>
996
+ <div class="card">
997
+ <div class="card-body">
998
+ <div class="table-responsive">
999
+ <table class="table table-hover">
1000
+ <thead><tr><th>Имя</th><th>PIN</th><th>Статус</th><th>Действия</th></tr></thead>
1001
+ <tbody>
1002
+ {% for u in users %}
1003
+ <tr>
1004
+ <td>{{ u.name }}</td><td>{{ u.pin }}</td>
1005
+ <td><span class="badge text-bg-{{ 'success' if u.is_active else 'secondary' }}">{{ 'Активен' if u.is_active else 'Неактивен' }}</span></td>
1006
+ <td>
1007
+ <button class="btn btn-sm btn-outline-primary" onclick="prepareEditUserModal('{{ u|tojson|safe }}')"><i class="fas fa-edit"></i></button>
1008
+ <form method="POST" action="{{ url_for('toggle_user_active', user_id=u.id) }}" class="d-inline">
1009
+ <button type="submit" class="btn btn-sm btn-outline-{{ 'warning' if u.is_active else 'success' }}">{{ 'Деактивировать' if u.is_active else 'Активировать' }}</button>
1010
+ </form>
1011
+ </td>
1012
+ </tr>
1013
+ {% endfor %}
1014
+ </tbody>
1015
+ </table>
1016
+ </div>
1017
+ </div>
1018
+ </div>
1019
+ <div class="modal fade" id="userModal" tabindex="-1">
1020
+ <div class="modal-dialog">
1021
+ <div class="modal-content">
1022
+ <div class="modal-header">
1023
+ <h5 class="modal-title" id="userModalLabel">Добавить пользователя</h5>
1024
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
1025
+ </div>
1026
+ <form method="POST">
1027
+ <input type="hidden" name="action" id="user_form_action" value="add">
1028
+ <input type="hidden" name="user_id" id="user_form_id">
1029
+ <div class="modal-body">
1030
+ <div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" id="user_form_name" class="form-control" required></div>
1031
+ <div class="mb-3"><label class="form-label">PIN (4 цифры)</label><input type="text" name="pin" id="user_form_pin" class="form-control" required pattern="[0-9]{4}" maxlength="4"></div>
1032
+ </div>
1033
+ <div class="modal-footer">
1034
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
1035
+ <button type="submit" class="btn btn-primary">Сохранить</button>
1036
+ </div>
1037
+ </form>
1038
+ </div>
1039
+ </div>
1040
+ </div>
1041
+ """
1042
+
1043
+ USERS_SCRIPTS = """
1044
+ <script>
1045
+ const userModal = new bootstrap.Modal(document.getElementById('userModal'));
1046
+
1047
+ function prepareAddUserModal() {
1048
+ document.getElementById('userModalLabel').textContent = 'Добавить пользователя';
1049
+ document.getElementById('user_form_action').value = 'add';
1050
+ document.querySelector('#userModal form').reset();
1051
+ document.getElementById('user_form_id').value = '';
1052
+ }
1053
+
1054
+ function prepareEditUserModal(userJson) {
1055
+ const user = JSON.parse(userJson);
1056
+ document.getElementById('userModalLabel').textContent = 'Редактировать пользователя';
1057
+ document.getElementById('user_form_action').value = 'edit';
1058
+ document.getElementById('user_form_id').value = user.id;
1059
+ document.getElementById('user_form_name').value = user.name;
1060
+ document.getElementById('user_form_pin').value = user.pin;
1061
+ userModal.show();
1062
+ }
1063
+ </script>
1064
+ """
1065
+
1066
+ ADMIN_CONTENT = """
1067
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
1068
+ <h1 class="h2">Админ-панель</h1>
1069
+ </div>
1070
+ <div class="row">
1071
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Продаж сегодня</h6><p class="card-text fs-2 fw-bold">{{ stats.sales_today_count }}</p></div></div></div>
1072
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Выручка сегодня</h6><p class="card-text fs-2 fw-bold">{{ format_currency_py(stats.sales_today_revenue) }}</p></div></div></div>
1073
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Наличные в кассах</h6><p class="card-text fs-2 fw-bold">{{ format_currency_py(stats.total_cash_in_kassas) }}</p></div></div></div>
1074
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Позиций в инвентаре</h6><p class="card-text fs-2 fw-bold">{{ stats.total_products }}</p></div></div></div>
1075
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Стоимость склада (закуп)</h6><p class="card-text fs-2 fw-bold">{{ format_currency_py(stats.total_stock_value_purchase) }}</p></div></div></div>
1076
+ <div class="col-md-4 mb-3"><div class="card"><div class="card-body"><h6 class="card-subtitle text-muted">Стоимость склада (продажа)</h6><p class="card-text fs-2 fw-bold">{{ format_currency_py(stats.total_stock_value_sale) }}</p></div></div></div>
1077
+ </div>
1078
+ <div class="card mt-4">
1079
+ <div class="card-header"><h5 class="mb-0">Управление данными</h5></div>
1080
+ <div class="card-body">
1081
+ <p>Синхронизация данных с облачным хранилищем Hugging Face.</p>
1082
+ <form method="POST" action="{{ url_for('backup_hf') }}" class="d-inline me-2">
1083
+ <button type="submit" class="btn btn-primary"><i class="fas fa-cloud-upload-alt me-1"></i> Сохранить в облако</button>
1084
+ </form>
1085
+ <form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ВНИМАНИЕ! Это перезапишет все локальные данные последней версией из облака. Продолжить?');" class="d-inline">
1086
+ <button type="submit" class="btn btn-secondary"><i class="fas fa-cloud-download-alt me-1"></i> Загрузить из облака</button>
1087
+ </form>
1088
+ </div>
1089
+ </div>
1090
+ """
1091
+
1092
+ BASE_TEMPLATE = """
1093
+ <!DOCTYPE html>
1094
+ <html lang="ru" data-bs-theme="light">
1095
+ <head>
1096
+ <meta charset="UTF-8">
1097
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1098
+ <title>__TITLE__ - POS</title>
1099
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
1100
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1101
+ <style>
1102
+ :root { --sidebar-width: 260px; --topbar-height: 60px; }
1103
+ body { background-color: #f3f4f6; }
1104
+ .sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background-color: #111827; padding-top: 1.5rem; }
1105
+ .sidebar-brand { font-size: 1.5rem; font-weight: 700; color: white; text-decoration: none; }
1106
+ .sidebar .nav-link { color: #9ca3af; font-weight: 500; padding: 0.75rem 1.5rem; margin: 0.125rem 0.5rem; border-radius: 0.375rem; }
1107
+ .sidebar .nav-link:hover { color: #ffffff; background-color: #374151; }
1108
+ .sidebar .nav-link.active { color: #ffffff; background-color: #4f46e5; }
1109
+ .sidebar .nav-link i.fa-fw { width: 1.25em; margin-right: 0.75rem; }
1110
+ .main-content { margin-left: var(--sidebar-width); padding: var(--topbar-height) 1.5rem 1.5rem; }
1111
+ .topbar { position: fixed; top: 0; right: 0; left: var(--sidebar-width); height: var(--topbar-height); background: #ffffff; border-bottom: 1px solid #e5e7eb; padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; z-index: 1020;}
1112
+ .topbar-info { font-size: 0.9rem; color: #6b7280; }
1113
+ .topbar-info i { color: #4f46e5; }
1114
+ {% if no_sidebar %}
1115
+ .main-content { margin-left: 0; }
1116
+ .topbar { display: none; }
1117
+ .sidebar { display: none; }
1118
+ {% endif %}
1119
+ </style>
1120
+ </head>
1121
+ <body>
1122
+ <div class="sidebar">
1123
+ <div class="text-center pb-3 border-bottom border-secondary-subtle">
1124
+ <a href="{{ url_for('pos') }}" class="sidebar-brand">
1125
+ <i class="fas fa-cash-register"></i> POS
1126
+ </a>
1127
+ </div>
1128
+ <nav class="nav flex-column sidebar-nav mt-3">
1129
+ <a class="nav-link {% if request.endpoint == 'pos' %}active{% endif %}" href="{{ url_for('pos') }}"><i class="fas fa-fw fa-dollar-sign"></i>Касса</a>
1130
+ <a class="nav-link {% if request.endpoint == 'inventory_page' %}active{% endif %}" href="{{ url_for('inventory_page') }}"><i class="fas fa-fw fa-boxes"></i>Инвентарь</a>
1131
+ <a class="nav-link {% if request.endpoint == 'kassa_page' %}active{% endif %}" href="{{ url_for('kassa_page') }}"><i class="fas fa-fw fa-calculator"></i>Учет касс</a>
1132
+ <a class="nav-link {% if request.endpoint == 'users_page' %}active{% endif %}" href="{{ url_for('users_page') }}"><i class="fas fa-fw fa-users"></i>Пользователи</a>
1133
+ <a class="nav-link {% if request.endpoint == 'admin_panel' %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cog"></i>Админ</a>
1134
+ </nav>
1135
+ <div class="mt-auto p-3 text-center">
1136
+ <a href="{{ url_for('logout') }}" class="btn btn-sm btn-outline-danger w-100">Выйти</a>
1137
+ </div>
1138
+ </div>
1139
+
1140
+ <div class="main-content">
1141
+ <header class="topbar">
1142
+ <h4 class="h5 mb-0">__TITLE__</h4>
1143
+ {% if session.user_name %}
1144
+ <div class="topbar-info">
1145
+ <span class="me-3"><i class="fas fa-user me-1"></i> <strong>{{ session.user_name }}</strong></span>
1146
+ <span><i class="fas fa-desktop me-1"></i> {{ session.kassa_name }}</span>
1147
+ </div>
1148
+ {% endif %}
1149
+ </header>
1150
+
1151
+ <main>
1152
+ {% with messages = get_flashed_messages(with_categories=true) %}
1153
+ {% if messages %}
1154
+ <div class="flash-messages mb-3">
1155
+ {% for category, message in messages %}
1156
+ <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
1157
+ {{ message }}
1158
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
1159
+ </div>
1160
+ {% endfor %}
1161
+ </div>
1162
+ {% endif %}
1163
+ {% endwith %}
1164
+ __CONTENT__
1165
+ </main>
1166
+ </div>
1167
+
1168
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
1169
+ <script>
1170
+ function formatCurrencyJS(value) {
1171
+ const number = parseFloat(String(value).replace(/\\s/g, '').replace(',', '.'));
1172
+ return isNaN(number) ? '0,00' : number.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1173
+ }
1174
+ </script>
1175
+ __SCRIPTS__
1176
+ </body>
1177
+ </html>
1178
+ """
1179
+
1180
+ @app.context_processor
1181
+ def inject_utils():
1182
+ return {
1183
+ 'format_currency_py': format_currency_py,
1184
+ }
1185
+
1186
+ if __name__ == '__main__':
1187
+ load_inventory()
1188
+ load_sales()
1189
+ load_users()
1190
+ load_kassa()
1191
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1192
+ backup_thread.start()
1193
+ app.run(debug=False, host='0.0.0.0', port=7860, use_reloader=False)