Kgshop commited on
Commit
40ac2f2
·
verified ·
1 Parent(s): 8c7b185

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +176 -187
app.py CHANGED
@@ -145,11 +145,11 @@ def load_data():
145
  'admin_password': '',
146
  'logo_url': DEFAULT_LOGO_URL,
147
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
148
- 'currency': '₸',
149
  'track_inventory': False,
150
  'use_barcodes': False,
151
  'business_type': 'mixed',
152
  'system_mode': 'both',
 
153
  'customer_fields': {
154
  'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False
155
  },
@@ -179,11 +179,11 @@ def load_data():
179
  if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True
180
  if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
181
  if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
182
- if 'currency' not in settings: settings['currency'] = '₸'; changed = True
183
  if 'track_inventory' not in settings: settings['track_inventory'] = False; changed = True
184
  if 'use_barcodes' not in settings: settings['use_barcodes'] = False; changed = True
185
  if 'business_type' not in settings: settings['business_type'] = 'mixed'; changed = True
186
  if 'system_mode' not in settings: settings['system_mode'] = 'both'; changed = True
 
187
  if 'customer_fields' not in settings:
188
  settings['customer_fields'] = {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False}
189
  changed = True
@@ -214,9 +214,9 @@ def load_data():
214
  if 'status' not in order: order['status'] = 'confirmed'; changed = True
215
  if 'staff_name' not in order: order['staff_name'] = ''; changed = True
216
  if 'assembled' not in order: order['assembled'] = {}; changed = True
217
- if 'global_discount' not in order: order['global_discount'] = 0.0; changed = True
218
  for item in order.get('cart', []):
219
- if 'discount' not in item: item['discount'] = 0.0; changed = True
220
  if 'category' not in item: item['category'] = 'Без категории'; changed = True
221
 
222
  if changed or not os.path.exists(DATA_FILE):
@@ -258,11 +258,11 @@ def get_env_data(env_id):
258
  'admin_password': '',
259
  'logo_url': DEFAULT_LOGO_URL,
260
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
261
- 'currency': '₸',
262
  'track_inventory': False,
263
  'use_barcodes': False,
264
  'business_type': 'mixed',
265
  'system_mode': 'both',
 
266
  'customer_fields': {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
267
  'socials': {
268
  'wa': {'enabled': True, 'url': ''},
@@ -723,7 +723,7 @@ CATALOG_TEMPLATE = '''
723
  <div class="customer-form">
724
  {% if mode == 'pos' %}
725
  <div style="margin-top: 5px; margin-bottom: 15px; background: var(--bg); padding: 10px; border-radius: 12px; border: 1px solid var(--border);">
726
- <label style="font-size: 0.9rem; font-weight: 600; display:block; margin-bottom:5px;">Общая скидка на чек ({{ currency_code }})</label>
727
  <input type="number" id="globalDiscountVal" value="0" min="0" onchange="updateCartUI()" style="width: 100%; border:none; background:var(--surface); padding:10px; border-radius:8px; font-weight:600; outline:none;">
728
  </div>
729
  <input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
@@ -1136,7 +1136,7 @@ CATALOG_TEMPLATE = '''
1136
  unit = cBoxPrice / ppb;
1137
  }
1138
  let discountedUnit = unit - disc;
1139
- if(discountedUnit < 0) discountedUnit = 0;
1140
  return discountedUnit * qty;
1141
  }
1142
 
@@ -1187,7 +1187,7 @@ CATALOG_TEMPLATE = '''
1187
 
1188
  let discountHtml = '';
1189
  if(mode === 'pos') {
1190
- discountHtml = `<input type="number" style="width: 70px; font-size: 0.85rem; padding: 4px; border-radius:6px; border:1px solid var(--border); background:var(--surface); text-align:center; outline:none;" placeholder="Ск. ед." value="${item.discount || 0}" onchange="updateItemDiscount('${cKey}', this.value)" min="0" title="Скидка на 1 ед."> ${currency}`;
1191
  }
1192
 
1193
  list.innerHTML += `
@@ -1635,7 +1635,7 @@ ORDER_TEMPLATE = '''
1635
  <th style="text-align: left;">Наименование</th>
1636
  <th>Фото</th>
1637
  <th>Кол-во</th>
1638
- <th>Цена со скидкой</th>
1639
  <th>Сумма</th>
1640
  </tr>
1641
  </thead>
@@ -1655,7 +1655,7 @@ ORDER_TEMPLATE = '''
1655
  {% endif %}
1656
  <div style="font-size: 0.8rem; color: {% if assembled == item.quantity %}#00b894{% else %}#0984e3{% endif %}; margin-top: 4px; font-weight:600;">Собрано: {{ assembled }} / {{ item.quantity }}</div>
1657
  {% if item.discount and item.discount > 0 %}
1658
- <div style="font-size: 0.8rem; color: #e17055; margin-top: 2px;">Скидка на ед.: {{ item.discount }} {{ currency_code }}</div>
1659
  {% endif %}
1660
  </td>
1661
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
@@ -1697,7 +1697,7 @@ ORDER_TEMPLATE = '''
1697
  <tr class="total-row">
1698
  <td colspan="5" style="text-align: right; padding-right: 20px;">
1699
  {% if order.global_discount > 0 %}
1700
- <div style="color:#e17055; font-size:0.9rem; margin-bottom:5px;">Применена общая скидка: {{ order.global_discount }} {{ currency_code }}</div>
1701
  {% endif %}
1702
  Итого:
1703
  </td>
@@ -2175,7 +2175,7 @@ ADMIN_TEMPLATE = '''
2175
 
2176
  .order-item { background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
2177
  .order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; }
2178
- .order-actions { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
2179
 
2180
  .staff-item { display: flex; flex-direction: column; gap: 10px; background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
2181
 
@@ -2258,7 +2258,8 @@ ADMIN_TEMPLATE = '''
2258
  <div><b>Сумма:</b> {{ order.total_price }} {{ currency_code }}</div>
2259
  </div>
2260
  <div class="order-actions">
2261
- <button type="button" class="btn btn-outline" style="padding: 5px 10px; font-size: 0.85rem;" onclick="openDiscountModal('{{ order.id }}')"><i class="fas fa-percent"></i> Сделать скидку</button>
 
2262
  <form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;">
2263
  <input type="hidden" name="action" value="confirm">
2264
  <button type="submit" class="btn btn-success" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-check"></i> Подтвердить</button>
@@ -2337,6 +2338,15 @@ ADMIN_TEMPLATE = '''
2337
  <input type="text" name="organization_name" value="{{ settings.organization_name }}" required>
2338
  </div>
2339
 
 
 
 
 
 
 
 
 
 
2340
  <div class="settings-row">
2341
  <label>Валюта:</label>
2342
  <select name="currency">
@@ -2346,15 +2356,6 @@ ADMIN_TEMPLATE = '''
2346
  <option value="$" {% if settings.currency == '$' %}selected{% endif %}>Доллар США ($)</option>
2347
  </select>
2348
  </div>
2349
-
2350
- <div class="settings-row">
2351
- <label>Тип бизнеса:</label>
2352
- <select name="business_type">
2353
- <option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
2354
- <option value="mixed" {% if settings.business_type == 'mixed' %}selected{% endif %}>Оптово-розничный</option>
2355
- <option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option>
2356
- </select>
2357
- </div>
2358
 
2359
  <div class="settings-row">
2360
  <label>WhatsApp магазина:</label>
@@ -2697,24 +2698,20 @@ ADMIN_TEMPLATE = '''
2697
  <button class="btn btn-danger" onclick="stopScanner()">Отмена</button>
2698
  </div>
2699
  </div>
2700
-
2701
  <div class="modal-overlay" id="discountModal" style="z-index:9999; display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
2702
- <div style="background:#fff; padding:20px; border-radius:12px; width:100%; max-width:500px; max-height:90vh; overflow-y:auto; text-align:left;">
2703
- <h3 style="margin-top:0;">Скидка на заказ <span id="discountOrderIdDisplay"></span></h3>
2704
- <form id="discountForm" method="POST" action="">
2705
- <input type="hidden" name="action" value="apply_discount">
2706
- <input type="hidden" name="order_id" id="discountOrderId">
2707
-
2708
- <div style="margin-bottom: 15px;">
2709
- <label style="font-weight: bold; display:block; margin-bottom:5px;">Общая скидка на сумму чека ({{ currency_code }}):</label>
2710
- <input type="number" name="global_discount" id="globalDiscountInput" step="0.01" min="0" placeholder="Сумма скидки" style="width: 100%; padding: 10px; border-radius: 8px; border: 1px solid #ccc; outline: none;">
2711
  </div>
2712
-
2713
- <div style="font-weight: bold; margin-bottom:10px;">Скидка на позиции (на 1 ед. в {{ currency_code }}):</div>
2714
- <div id="discountItemsList" style="display:flex; flex-direction:column; gap:10px;"></div>
2715
-
2716
  <div style="margin-top:20px; display:flex; gap:10px; justify-content:flex-end;">
2717
- <button type="button" class="btn btn-outline" onclick="closeDiscountModal()">Отмена</button>
2718
  <button type="submit" class="btn btn-success">Сохранить</button>
2719
  </div>
2720
  </form>
@@ -2722,39 +2719,10 @@ ADMIN_TEMPLATE = '''
2722
  </div>
2723
 
2724
  <script>
 
2725
  const trackInventory = {{ 'true' if settings.track_inventory and sys_mode != 'external' else 'false' }};
2726
  const useBarcodes = {{ 'true' if settings.use_barcodes and sys_mode != 'external' else 'false' }};
2727
  const businessType = '{{ settings.business_type }}';
2728
-
2729
- const pendingOrdersData = {{ pending_orders | tojson }};
2730
-
2731
- function openDiscountModal(orderId) {
2732
- const order = pendingOrdersData.find(o => o.id === orderId);
2733
- if(!order) return;
2734
- document.getElementById('discountOrderIdDisplay').innerText = orderId;
2735
- document.getElementById('discountOrderId').value = orderId;
2736
- document.getElementById('globalDiscountInput').value = order.global_discount || 0;
2737
-
2738
- const list = document.getElementById('discountItemsList');
2739
- list.innerHTML = '';
2740
- order.cart.forEach(item => {
2741
- list.innerHTML += `
2742
- <div style="background:#fafafa; padding:10px; border-radius:8px; border:1px solid #e0e6ed;">
2743
- <div style="font-size:0.9rem; margin-bottom:5px; font-weight:600;">${item.name} ${item.variant_name ? '('+item.variant_name+')' : ''} <span style="color:#0984e3;">— ${item.quantity} шт.</span></div>
2744
- <div style="display:flex; align-items:center; gap:10px;">
2745
- <label style="font-size:0.85rem; color:#636e72;">Скидка на 1 ед.:</label>
2746
- <input type="number" name="item_discount_${item.c_key}" value="${item.discount || 0}" step="0.01" min="0" style="width:100px; padding:8px; border-radius:6px; border:1px solid #ccc; outline:none;">
2747
- </div>
2748
- </div>
2749
- `;
2750
- });
2751
-
2752
- document.getElementById('discountModal').style.display = 'flex';
2753
- }
2754
-
2755
- function closeDiscountModal() {
2756
- document.getElementById('discountModal').style.display = 'none';
2757
- }
2758
 
2759
  function showLoading(form) {
2760
  const btn = form.querySelector('button[type="submit"]');
@@ -3008,6 +2976,36 @@ ADMIN_TEMPLATE = '''
3008
  document.getElementById('scannerModal').style.display = 'none';
3009
  }
3010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3011
  document.querySelectorAll('.add-product-wrapper').forEach(wrapper => {
3012
  const cb = wrapper.querySelector('input[name="has_variant_prices"]');
3013
  if(cb) toggleVariantPrices(cb, wrapper.id);
@@ -3076,10 +3074,10 @@ REPORTS_TEMPLATE = '''
3076
  <div class="tabs">
3077
  <div class="tab active" onclick="switchTab('general')">Общий отчет</div>
3078
  <div class="tab" onclick="switchTab('daily')">По дням</div>
3079
- <div class="tab" onclick="switchTab('hourly')">По часам</div>
3080
  <div class="tab" onclick="switchTab('category')">По категориям</div>
3081
  <div class="tab" onclick="switchTab('staff')">По сотрудникам</div>
3082
- <div class="tab" onclick="switchTab('customers')">По клиентам</div>
 
3083
  </div>
3084
 
3085
  <div id="general" class="tab-content active">
@@ -3119,13 +3117,6 @@ REPORTS_TEMPLATE = '''
3119
  <i class="fas fa-undo icon"></i>
3120
  </div>
3121
  </div>
3122
- <div class="stat-card">
3123
- <div class="stat-card-inner">
3124
- <div class="title">Оценочн��я стоимость склада</div>
3125
- <div class="value" id="warehouseValue" style="color:var(--success);">0</div>
3126
- <i class="fas fa-cubes icon"></i>
3127
- </div>
3128
- </div>
3129
  </div>
3130
 
3131
  <div class="table-container">
@@ -3159,22 +3150,6 @@ REPORTS_TEMPLATE = '''
3159
  </div>
3160
  </div>
3161
 
3162
- <div id="hourly" class="tab-content">
3163
- <div class="table-container">
3164
- <h3>Активность по часам (За все выбранное время)</h3>
3165
- <table>
3166
- <thead>
3167
- <tr>
3168
- <th>Час</th>
3169
- <th>Кол-во заказов</th>
3170
- <th>Выручка ({{ currency_code }})</th>
3171
- </tr>
3172
- </thead>
3173
- <tbody id="hourlySalesTable"></tbody>
3174
- </table>
3175
- </div>
3176
- </div>
3177
-
3178
  <div id="category" class="tab-content">
3179
  <div class="table-container">
3180
  <h3>Продажи по категориям</h3>
@@ -3206,19 +3181,32 @@ REPORTS_TEMPLATE = '''
3206
  </table>
3207
  </div>
3208
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3209
 
3210
- <div id="customers" class="tab-content">
3211
  <div class="table-container">
3212
- <h3>Топ клиентов</h3>
3213
  <table>
3214
- <thead>
3215
- <tr>
3216
- <th>Клиент</th>
3217
- <th>Кол-во заказов</th>
3218
- <th>Сумма покупок ({{ currency_code }})</th>
3219
- </tr>
3220
- </thead>
3221
- <tbody id="customersSalesTable"></tbody>
3222
  </table>
3223
  </div>
3224
  </div>
@@ -3226,7 +3214,6 @@ REPORTS_TEMPLATE = '''
3226
 
3227
  <script>
3228
  const allOrders = {{ orders_json|safe }};
3229
- const allProducts = {{ products_json|safe }};
3230
 
3231
  function switchTab(tabId) {
3232
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -3272,12 +3259,10 @@ REPORTS_TEMPLATE = '''
3272
  let productSales = {};
3273
  let dailySales = {};
3274
  let categorySales = {};
 
 
3275
  let hourlySales = {};
3276
- let customerSales = {};
3277
-
3278
- for(let i=0; i<24; i++) {
3279
- hourlySales[i] = { orders: 0, sum: 0 };
3280
- }
3281
 
3282
  filteredOrders.forEach(o => {
3283
  if(o.status === 'returned') {
@@ -3291,27 +3276,24 @@ REPORTS_TEMPLATE = '''
3291
  staffSales[staff].orders += 1;
3292
  staffSales[staff].sum += o.total_price;
3293
 
3294
- const dateStr = o.created_at.split(' ')[0];
3295
- if(!dailySales[dateStr]) dailySales[dateStr] = { orders: 0, sum: 0 };
3296
- dailySales[dateStr].orders += 1;
3297
- dailySales[dateStr].sum += o.total_price;
3298
-
3299
- const timeStr = o.created_at.split(' ')[1];
3300
- if(timeStr) {
3301
- const hour = parseInt(timeStr.split(':')[0]);
3302
- if(!isNaN(hour)) {
3303
- hourlySales[hour].orders += 1;
3304
- hourlySales[hour].sum += o.total_price;
 
 
 
 
 
 
3305
  }
3306
- }
3307
-
3308
- let cName = o.customer_name ? o.customer_name.trim() : '';
3309
- let cPhone = o.customer_phone ? o.customer_phone.trim() : '';
3310
- let cKey = cName + (cPhone ? ' (' + cPhone + ')' : '');
3311
- if(cKey && cKey !== '()') {
3312
- if(!customerSales[cKey]) customerSales[cKey] = { orders: 0, sum: 0 };
3313
- customerSales[cKey].orders += 1;
3314
- customerSales[cKey].sum += o.total_price;
3315
  }
3316
 
3317
  o.cart.forEach(item => {
@@ -3343,31 +3325,12 @@ REPORTS_TEMPLATE = '''
3343
  let aov = ordersCount > 0 ? (totalRev / ordersCount) : 0;
3344
  document.getElementById('avgOrderValue').innerText = Math.round(aov).toLocaleString() + ' {{ currency_code }}';
3345
 
3346
- let whValue = 0;
3347
- allProducts.forEach(p => {
3348
- if(p.variants && p.variants.length > 0) {
3349
- p.variants.forEach(v => {
3350
- let s = parseInt(v.stock);
3351
- if(!isNaN(s) && s > 0) {
3352
- let price = p.has_variant_prices ? parseFloat(v.price) : parseFloat(p.price);
3353
- whValue += s * (price || 0);
3354
- }
3355
- });
3356
- } else {
3357
- let s = parseInt(p.stock);
3358
- if(!isNaN(s) && s > 0) {
3359
- whValue += s * (parseFloat(p.price) || 0);
3360
- }
3361
- }
3362
- });
3363
- document.getElementById('warehouseValue').innerText = Math.round(whValue).toLocaleString() + ' {{ currency_code }}';
3364
-
3365
  renderTopProducts(productSales);
3366
  renderStaffTable(staffSales);
3367
  renderDailyTable(dailySales);
3368
- renderHourlyTable(hourlySales);
3369
  renderCategoryTable(categorySales);
3370
- renderCustomerTable(customerSales);
 
3371
  }
3372
 
3373
  function renderTopProducts(data) {
@@ -3427,27 +3390,6 @@ REPORTS_TEMPLATE = '''
3427
  });
3428
  }
3429
 
3430
- function renderHourlyTable(data) {
3431
- const tbody = document.getElementById('hourlySalesTable');
3432
- tbody.innerHTML = '';
3433
- let hasData = false;
3434
- for(let i=0; i<24; i++) {
3435
- if(data[i].orders > 0) {
3436
- hasData = true;
3437
- tbody.innerHTML += `
3438
- <tr>
3439
- <td style="font-weight:500;">${i.toString().padStart(2, '0')}:00 - ${i.toString().padStart(2, '0')}:59</td>
3440
- <td>${data[i].orders}</td>
3441
- <td>${Math.round(data[i].sum).toLocaleString()}</td>
3442
- </tr>
3443
- `;
3444
- }
3445
- }
3446
- if(!hasData) {
3447
- tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3448
- }
3449
- }
3450
-
3451
  function renderCategoryTable(data) {
3452
  const tbody = document.getElementById('categorySalesTable');
3453
  tbody.innerHTML = '';
@@ -3467,20 +3409,52 @@ REPORTS_TEMPLATE = '''
3467
  });
3468
  }
3469
 
3470
- function renderCustomerTable(data) {
3471
- const tbody = document.getElementById('customersSalesTable');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3472
  tbody.innerHTML = '';
3473
- const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 30);
3474
- if(sorted.length === 0) {
3475
- tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3476
  return;
3477
  }
3478
- sorted.forEach(c => {
 
 
 
 
 
 
 
 
 
 
 
 
3479
  tbody.innerHTML += `
3480
  <tr>
3481
- <td style="font-weight:500;">${c}</td>
3482
- <td>${data[c].orders}</td>
3483
- <td>${Math.round(data[c].sum).toLocaleString()}</td>
 
3484
  </tr>
3485
  `;
3486
  });
@@ -3492,25 +3466,40 @@ REPORTS_TEMPLATE = '''
3492
  </html>
3493
  '''
3494
 
3495
- @app.route('/<env_id>/apply_discount/<order_id>', methods=['POST'])
3496
- def apply_discount(env_id, order_id):
3497
  data = get_env_data(env_id)
 
3498
  order = data.get('orders', {}).get(order_id)
 
3499
  if order and order.get('status') == 'pending':
3500
- global_disc = float(request.form.get('global_discount') or 0)
 
 
 
 
3501
  order['global_discount'] = global_disc
 
3502
  for item in order.get('cart', []):
3503
  c_key = item.get('c_key')
3504
- item_disc = float(request.form.get(f'item_discount_{c_key}') or 0)
3505
- item['discount'] = item_disc
 
 
 
 
 
 
3506
 
3507
  update_order_totals(order, data['settings'].get('business_type', 'mixed'))
3508
  save_env_data(env_id, data)
3509
- flash('Скидка успешно применена', 'success')
3510
- else:
3511
- flash('Ошибка применения скидки', 'error')
3512
  return redirect(url_for('admin', env_id=env_id))
3513
 
 
 
 
 
3514
  if __name__ == '__main__':
3515
  download_db_from_hf()
3516
  load_data()
 
145
  'admin_password': '',
146
  'logo_url': DEFAULT_LOGO_URL,
147
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
 
148
  'track_inventory': False,
149
  'use_barcodes': False,
150
  'business_type': 'mixed',
151
  'system_mode': 'both',
152
+ 'currency': '₸',
153
  'customer_fields': {
154
  'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False
155
  },
 
179
  if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True
180
  if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
181
  if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
 
182
  if 'track_inventory' not in settings: settings['track_inventory'] = False; changed = True
183
  if 'use_barcodes' not in settings: settings['use_barcodes'] = False; changed = True
184
  if 'business_type' not in settings: settings['business_type'] = 'mixed'; changed = True
185
  if 'system_mode' not in settings: settings['system_mode'] = 'both'; changed = True
186
+ if 'currency' not in settings: settings['currency'] = '₸'; changed = True
187
  if 'customer_fields' not in settings:
188
  settings['customer_fields'] = {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False}
189
  changed = True
 
214
  if 'status' not in order: order['status'] = 'confirmed'; changed = True
215
  if 'staff_name' not in order: order['staff_name'] = ''; changed = True
216
  if 'assembled' not in order: order['assembled'] = {}; changed = True
217
+ if 'global_discount' not in order: order['global_discount'] = 0; changed = True
218
  for item in order.get('cart', []):
219
+ if 'discount' not in item: item['discount'] = 0; changed = True
220
  if 'category' not in item: item['category'] = 'Без категории'; changed = True
221
 
222
  if changed or not os.path.exists(DATA_FILE):
 
258
  'admin_password': '',
259
  'logo_url': DEFAULT_LOGO_URL,
260
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
 
261
  'track_inventory': False,
262
  'use_barcodes': False,
263
  'business_type': 'mixed',
264
  'system_mode': 'both',
265
+ 'currency': '₸',
266
  'customer_fields': {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
267
  'socials': {
268
  'wa': {'enabled': True, 'url': ''},
 
723
  <div class="customer-form">
724
  {% if mode == 'pos' %}
725
  <div style="margin-top: 5px; margin-bottom: 15px; background: var(--bg); padding: 10px; border-radius: 12px; border: 1px solid var(--border);">
726
+ <label style="font-size: 0.9rem; font-weight: 600; display:block; margin-bottom:5px;">Общая скидка на чек (сумма)</label>
727
  <input type="number" id="globalDiscountVal" value="0" min="0" onchange="updateCartUI()" style="width: 100%; border:none; background:var(--surface); padding:10px; border-radius:8px; font-weight:600; outline:none;">
728
  </div>
729
  <input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
 
1136
  unit = cBoxPrice / ppb;
1137
  }
1138
  let discountedUnit = unit - disc;
1139
+ if (discountedUnit < 0) discountedUnit = 0;
1140
  return discountedUnit * qty;
1141
  }
1142
 
 
1187
 
1188
  let discountHtml = '';
1189
  if(mode === 'pos') {
1190
+ discountHtml = `<input type="number" style="width: 70px; font-size: 0.85rem; padding: 4px; border-radius:6px; border:1px solid var(--border); background:var(--surface); text-align:center; outline:none;" placeholder="Скидка" value="${item.discount || 0}" onchange="updateItemDiscount('${cKey}', this.value)" min="0" title="Скидка на 1 шт"> ${currency}`;
1191
  }
1192
 
1193
  list.innerHTML += `
 
1635
  <th style="text-align: left;">Наименование</th>
1636
  <th>Фото</th>
1637
  <th>Кол-во</th>
1638
+ <th>Цена</th>
1639
  <th>Сумма</th>
1640
  </tr>
1641
  </thead>
 
1655
  {% endif %}
1656
  <div style="font-size: 0.8rem; color: {% if assembled == item.quantity %}#00b894{% else %}#0984e3{% endif %}; margin-top: 4px; font-weight:600;">Собрано: {{ assembled }} / {{ item.quantity }}</div>
1657
  {% if item.discount and item.discount > 0 %}
1658
+ <div style="font-size: 0.8rem; color: #e17055; margin-top: 2px;">Скидка: {{ item.discount }} {{ currency_code }} (на 1 шт)</div>
1659
  {% endif %}
1660
  </td>
1661
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
 
1697
  <tr class="total-row">
1698
  <td colspan="5" style="text-align: right; padding-right: 20px;">
1699
  {% if order.global_discount > 0 %}
1700
+ <div style="color:#e17055; font-size:0.9rem; margin-bottom:5px;">Общая скидка: {{ order.global_discount }} {{ currency_code }}</div>
1701
  {% endif %}
1702
  Итого:
1703
  </td>
 
2175
 
2176
  .order-item { background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
2177
  .order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; }
2178
+ .order-actions { display: flex; gap: 10px; margin-top: 10px; }
2179
 
2180
  .staff-item { display: flex; flex-direction: column; gap: 10px; background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
2181
 
 
2258
  <div><b>Сумма:</b> {{ order.total_price }} {{ currency_code }}</div>
2259
  </div>
2260
  <div class="order-actions">
2261
+ <button class="btn btn-warning" onclick="openDiscountModal('{{ order.id }}')" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-tag"></i> Сделать скидку</button>
2262
+ <a href="/{{ env_id }}/order/{{ order.id }}" class="btn btn-outline" target="_blank" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-eye"></i> Накладная</a>
2263
  <form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;">
2264
  <input type="hidden" name="action" value="confirm">
2265
  <button type="submit" class="btn btn-success" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-check"></i> Подтвердить</button>
 
2338
  <input type="text" name="organization_name" value="{{ settings.organization_name }}" required>
2339
  </div>
2340
 
2341
+ <div class="settings-row">
2342
+ <label>Тип бизнеса:</label>
2343
+ <select name="business_type">
2344
+ <option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
2345
+ <option value="mixed" {% if settings.business_type == 'mixed' %}selected{% endif %}>Оптово-розничный</option>
2346
+ <option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option>
2347
+ </select>
2348
+ </div>
2349
+
2350
  <div class="settings-row">
2351
  <label>Валюта:</label>
2352
  <select name="currency">
 
2356
  <option value="$" {% if settings.currency == '$' %}selected{% endif %}>Доллар США ($)</option>
2357
  </select>
2358
  </div>
 
 
 
 
 
 
 
 
 
2359
 
2360
  <div class="settings-row">
2361
  <label>WhatsApp магазина:</label>
 
2698
  <button class="btn btn-danger" onclick="stopScanner()">Отмена</button>
2699
  </div>
2700
  </div>
2701
+
2702
  <div class="modal-overlay" id="discountModal" style="z-index:9999; display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
2703
+ <div style="background:#fff; padding:20px; border-radius:12px; width:100%; max-width:600px; max-height:90vh; overflow-y:auto; position:relative;">
2704
+ <h3 style="margin-top:0;">Скидка для заказа <span id="discOrderId"></span></h3>
2705
+ <form method="POST" action="/{{ env_id }}/apply_discount">
2706
+ <input type="hidden" name="order_id" id="discOrderInput">
2707
+ <div style="margin-bottom:15px;">
2708
+ <label style="font-weight:bold;">Общая скидка на заказ (в сумме):</label>
2709
+ <input type="number" name="global_discount" id="discGlobal" step="0.01" min="0" style="width:100%; padding:10px; border-radius:8px; border:1px solid #ccc; margin-top:5px;">
 
 
2710
  </div>
2711
+ <h4 style="margin-bottom:10px;">Скидка на единицу товара:</h4>
2712
+ <div id="discItemsContainer" style="display:flex; flex-direction:column; gap:10px;"></div>
 
 
2713
  <div style="margin-top:20px; display:flex; gap:10px; justify-content:flex-end;">
2714
+ <button type="button" class="btn btn-danger" onclick="closeDiscountModal()">Отмена</button>
2715
  <button type="submit" class="btn btn-success">Сохранить</button>
2716
  </div>
2717
  </form>
 
2719
  </div>
2720
 
2721
  <script>
2722
+ const pendingOrdersData = {{ pending_orders | tojson }};
2723
  const trackInventory = {{ 'true' if settings.track_inventory and sys_mode != 'external' else 'false' }};
2724
  const useBarcodes = {{ 'true' if settings.use_barcodes and sys_mode != 'external' else 'false' }};
2725
  const businessType = '{{ settings.business_type }}';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2726
 
2727
  function showLoading(form) {
2728
  const btn = form.querySelector('button[type="submit"]');
 
2976
  document.getElementById('scannerModal').style.display = 'none';
2977
  }
2978
 
2979
+ function openDiscountModal(orderId) {
2980
+ const order = pendingOrdersData.find(o => o.id === orderId);
2981
+ if(!order) return;
2982
+ document.getElementById('discOrderId').innerText = order.id;
2983
+ document.getElementById('discOrderInput').value = order.id;
2984
+ document.getElementById('discGlobal').value = order.global_discount || 0;
2985
+
2986
+ const container = document.getElementById('discItemsContainer');
2987
+ container.innerHTML = '';
2988
+ order.cart.forEach(item => {
2989
+ let itemName = item.name + (item.variant_name ? ` (${item.variant_name})` : '');
2990
+ container.innerHTML += `
2991
+ <div style="background:#fafafa; border:1px solid #e0e6ed; border-radius:8px; padding:10px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px;">
2992
+ <div style="flex:1; min-width:200px;">
2993
+ <div style="font-weight:600; font-size:0.95rem;">${itemName}</div>
2994
+ <div style="font-size:0.85rem; color:#636e72;">Цена: ${item.price} | Кол-во: ${item.quantity}</div>
2995
+ </div>
2996
+ <div style="width:120px;">
2997
+ <input type="number" name="disc_item_${item.c_key}" value="${item.discount || 0}" step="0.01" min="0" style="width:100%; padding:8px; border-radius:6px; border:1px solid #ccc;" placeholder="Скидка (сумма)">
2998
+ </div>
2999
+ </div>
3000
+ `;
3001
+ });
3002
+ document.getElementById('discountModal').style.display = 'flex';
3003
+ }
3004
+
3005
+ function closeDiscountModal() {
3006
+ document.getElementById('discountModal').style.display = 'none';
3007
+ }
3008
+
3009
  document.querySelectorAll('.add-product-wrapper').forEach(wrapper => {
3010
  const cb = wrapper.querySelector('input[name="has_variant_prices"]');
3011
  if(cb) toggleVariantPrices(cb, wrapper.id);
 
3074
  <div class="tabs">
3075
  <div class="tab active" onclick="switchTab('general')">Общий отчет</div>
3076
  <div class="tab" onclick="switchTab('daily')">По дням</div>
 
3077
  <div class="tab" onclick="switchTab('category')">По категориям</div>
3078
  <div class="tab" onclick="switchTab('staff')">По сотрудникам</div>
3079
+ <div class="tab" onclick="switchTab('time')">По времени</div>
3080
+ <div class="tab" onclick="switchTab('abc')">ABC-Анализ</div>
3081
  </div>
3082
 
3083
  <div id="general" class="tab-content active">
 
3117
  <i class="fas fa-undo icon"></i>
3118
  </div>
3119
  </div>
 
 
 
 
 
 
 
3120
  </div>
3121
 
3122
  <div class="table-container">
 
3150
  </div>
3151
  </div>
3152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3153
  <div id="category" class="tab-content">
3154
  <div class="table-container">
3155
  <h3>Продажи по категориям</h3>
 
3181
  </table>
3182
  </div>
3183
  </div>
3184
+
3185
+ <div id="time" class="tab-content">
3186
+ <div class="stats-grid">
3187
+ <div class="table-container">
3188
+ <h3>По дням недели</h3>
3189
+ <table>
3190
+ <thead><tr><th>День недели</th><th>Заказов</th><th>Сумма ({{ currency_code }})</th></tr></thead>
3191
+ <tbody id="weekdaySalesTable"></tbody>
3192
+ </table>
3193
+ </div>
3194
+ <div class="table-container">
3195
+ <h3>По часам</h3>
3196
+ <table>
3197
+ <thead><tr><th>Час</th><th>Заказов</th><th>Сумма ({{ currency_code }})</th></tr></thead>
3198
+ <tbody id="hourlySalesTable"></tbody>
3199
+ </table>
3200
+ </div>
3201
+ </div>
3202
+ </div>
3203
 
3204
+ <div id="abc" class="tab-content">
3205
  <div class="table-container">
3206
+ <h3>ABC-Анализ товаров (по выручке)</h3>
3207
  <table>
3208
+ <thead><tr><th>Товар</th><th>Выручка ({{ currency_code }})</th><th>Доля (%)</th><th>Группа</th></tr></thead>
3209
+ <tbody id="abcTable"></tbody>
 
 
 
 
 
 
3210
  </table>
3211
  </div>
3212
  </div>
 
3214
 
3215
  <script>
3216
  const allOrders = {{ orders_json|safe }};
 
3217
 
3218
  function switchTab(tabId) {
3219
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
 
3259
  let productSales = {};
3260
  let dailySales = {};
3261
  let categorySales = {};
3262
+
3263
+ let weekdaySales = {1:{n:'Пн',o:0,s:0}, 2:{n:'Вт',o:0,s:0}, 3:{n:'Ср',o:0,s:0}, 4:{n:'Чт',o:0,s:0}, 5:{n:'Пт',o:0,s:0}, 6:{n:'Сб',o:0,s:0}, 0:{n:'Вс',o:0,s:0}};
3264
  let hourlySales = {};
3265
+ for(let i=0; i<24; i++) hourlySales[i] = {orders:0, sum:0};
 
 
 
 
3266
 
3267
  filteredOrders.forEach(o => {
3268
  if(o.status === 'returned') {
 
3276
  staffSales[staff].orders += 1;
3277
  staffSales[staff].sum += o.total_price;
3278
 
3279
+ let parts = o.created_at.split(' ');
3280
+ if(parts.length === 2) {
3281
+ const dateStr = parts[0];
3282
+ if(!dailySales[dateStr]) dailySales[dateStr] = { orders: 0, sum: 0 };
3283
+ dailySales[dateStr].orders += 1;
3284
+ dailySales[dateStr].sum += o.total_price;
3285
+
3286
+ let dObj = new Date(parts[0] + 'T' + parts[1]);
3287
+ let wd = dObj.getDay();
3288
+ let hr = dObj.getHours();
3289
+ if(!isNaN(wd)) {
3290
+ weekdaySales[wd].o++;
3291
+ weekdaySales[wd].s += o.total_price;
3292
+ }
3293
+ if(!isNaN(hr)) {
3294
+ hourlySales[hr].orders++;
3295
+ hourlySales[hr].sum += o.total_price;
3296
  }
 
 
 
 
 
 
 
 
 
3297
  }
3298
 
3299
  o.cart.forEach(item => {
 
3325
  let aov = ordersCount > 0 ? (totalRev / ordersCount) : 0;
3326
  document.getElementById('avgOrderValue').innerText = Math.round(aov).toLocaleString() + ' {{ currency_code }}';
3327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3328
  renderTopProducts(productSales);
3329
  renderStaffTable(staffSales);
3330
  renderDailyTable(dailySales);
 
3331
  renderCategoryTable(categorySales);
3332
+ renderTimeTables(weekdaySales, hourlySales);
3333
+ renderABC(productSales, totalRev);
3334
  }
3335
 
3336
  function renderTopProducts(data) {
 
3390
  });
3391
  }
3392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3393
  function renderCategoryTable(data) {
3394
  const tbody = document.getElementById('categorySalesTable');
3395
  tbody.innerHTML = '';
 
3409
  });
3410
  }
3411
 
3412
+ function renderTimeTables(wdData, hrData) {
3413
+ const wTbody = document.getElementById('weekdaySalesTable');
3414
+ wTbody.innerHTML = '';
3415
+ for(let i=1; i<=7; i++) {
3416
+ let idx = i === 7 ? 0 : i;
3417
+ if(wdData[idx].o > 0) {
3418
+ wTbody.innerHTML += `<tr><td>${wdData[idx].n}</td><td>${wdData[idx].o}</td><td>${Math.round(wdData[idx].s).toLocaleString()}</td></tr>`;
3419
+ }
3420
+ }
3421
+
3422
+ const hTbody = document.getElementById('hourlySalesTable');
3423
+ hTbody.innerHTML = '';
3424
+ for(let i=0; i<24; i++) {
3425
+ if(hrData[i].orders > 0) {
3426
+ let hrStr = i.toString().padStart(2, '0') + ':00';
3427
+ hTbody.innerHTML += `<tr><td>${hrStr}</td><td>${hrData[i].orders}</td><td>${Math.round(hrData[i].sum).toLocaleString()}</td></tr>`;
3428
+ }
3429
+ }
3430
+ }
3431
+
3432
+ function renderABC(data, totalRev) {
3433
+ const tbody = document.getElementById('abcTable');
3434
  tbody.innerHTML = '';
3435
+ if(totalRev <= 0) {
3436
+ tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;">Нет данных</td></tr>';
 
3437
  return;
3438
  }
3439
+ const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
3440
+ let cumulative = 0;
3441
+ sorted.forEach(p => {
3442
+ if (data[p].sum <= 0) return;
3443
+ cumulative += data[p].sum;
3444
+ let pct = (cumulative / totalRev) * 100;
3445
+ let group = 'A';
3446
+ if (pct > 80 && pct <= 95) group = 'B';
3447
+ else if (pct > 95) group = 'C';
3448
+
3449
+ let share = (data[p].sum / totalRev) * 100;
3450
+ let color = group === 'A' ? '#27ae60' : (group === 'B' ? '#f39c12' : '#e17055');
3451
+
3452
  tbody.innerHTML += `
3453
  <tr>
3454
+ <td>${p}</td>
3455
+ <td>${Math.round(data[p].sum).toLocaleString()}</td>
3456
+ <td>${share.toFixed(1)}%</td>
3457
+ <td style="font-weight:bold; color:${color};">${group}</td>
3458
  </tr>
3459
  `;
3460
  });
 
3466
  </html>
3467
  '''
3468
 
3469
+ @app.route('/<env_id>/apply_discount', methods=['POST'])
3470
+ def apply_discount(env_id):
3471
  data = get_env_data(env_id)
3472
+ order_id = request.form.get('order_id')
3473
  order = data.get('orders', {}).get(order_id)
3474
+
3475
  if order and order.get('status') == 'pending':
3476
+ try:
3477
+ global_disc = float(request.form.get('global_discount', 0))
3478
+ except ValueError:
3479
+ global_disc = 0
3480
+ if global_disc < 0: global_disc = 0
3481
  order['global_discount'] = global_disc
3482
+
3483
  for item in order.get('cart', []):
3484
  c_key = item.get('c_key')
3485
+ disc_val_str = request.form.get(f'disc_item_{c_key}')
3486
+ if disc_val_str is not None:
3487
+ try:
3488
+ disc_val = float(disc_val_str)
3489
+ if disc_val < 0: disc_val = 0
3490
+ item['discount'] = disc_val
3491
+ except ValueError:
3492
+ pass
3493
 
3494
  update_order_totals(order, data['settings'].get('business_type', 'mixed'))
3495
  save_env_data(env_id, data)
3496
+
 
 
3497
  return redirect(url_for('admin', env_id=env_id))
3498
 
3499
+ @app.route('/<env_id>/update_settings', methods=['POST'])
3500
+ def update_settings(env_id):
3501
+ pass
3502
+
3503
  if __name__ == '__main__':
3504
  download_db_from_hf()
3505
  load_data()