Kgshop commited on
Commit
04424da
·
verified ·
1 Parent(s): aaaf08e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +340 -56
app.py CHANGED
@@ -213,6 +213,10 @@ def load_data():
213
  if 'status' not in order: order['status'] = 'confirmed'; changed = True
214
  if 'staff_name' not in order: order['staff_name'] = ''; changed = True
215
  if 'assembled' not in order: order['assembled'] = {}; changed = True
 
 
 
 
216
 
217
  if changed or not os.path.exists(DATA_FILE):
218
  try:
@@ -275,22 +279,27 @@ def save_env_data(env_id, env_data):
275
 
276
  def update_order_totals(order, business_type):
277
  total = 0
 
278
  for i in order['cart']:
279
  qty = int(i.get('quantity', 0))
280
  if qty <= 0:
281
- i['calculated_price'] = float(i.get('price', 0))
282
  continue
283
  ppb = int(i.get('pieces_per_box', 1))
284
  c_price = float(i.get('price', 0))
285
  c_box_price = float(i.get('cart_box_price', 0))
 
286
 
287
  if business_type in ['mixed', 'wholesale'] and c_box_price > 0 and ppb > 1 and qty >= ppb:
288
- item_total = (c_box_price / ppb) * qty
289
  else:
290
- item_total = c_price * qty
291
 
292
- i['calculated_price'] = round(item_total / qty, 2)
 
 
293
  total += item_total
 
 
294
  order['total_price'] = round(total, 2)
295
 
296
  def is_order_fully_assembled(order):
@@ -336,6 +345,19 @@ def restore_stock(c_key, pid, vidx, return_qty, products):
336
  p['stock'] = int(current_s) + return_qty
337
  break
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  LANDING_PAGE_TEMPLATE = '''
341
  <!DOCTYPE html>
@@ -693,6 +715,10 @@ CATALOG_TEMPLATE = '''
693
 
694
  <div class="customer-form">
695
  {% if mode == 'pos' %}
 
 
 
 
696
  <input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
697
  <input type="text" id="custWhatsapp" placeholder="WhatsApp клиента (напр. +77001234567) необязательно">
698
  {% else %}
@@ -1024,7 +1050,7 @@ CATALOG_TEMPLATE = '''
1024
  }
1025
  vName = p.variants[varIdx].name;
1026
  }
1027
- cart[cKey] = { ...p, quantity: 0, cart_price: price, cart_box_price: bPrice, pieces_per_box: pPpb, variant_name: vName, variant_idx: varIdx };
1028
  }
1029
 
1030
  let currentQty = cart[cKey].quantity;
@@ -1081,19 +1107,29 @@ CATALOG_TEMPLATE = '''
1081
  const pId = cKey.split('___')[0];
1082
  updateCart(pId, 0, num, true, cKey, moq);
1083
  }
 
 
 
 
 
 
 
 
 
 
1084
 
1085
  function calculateItemPrice(item) {
1086
  let ppb = parseInt(item.pieces_per_box) || 1;
1087
  let qty = item.quantity;
1088
  let cBoxPrice = parseFloat(item.cart_box_price) || 0;
1089
  let cPrice = parseFloat(item.cart_price) || 0;
 
1090
 
 
1091
  if ((businessType === 'mixed' || businessType === 'wholesale') && cBoxPrice > 0 && ppb > 1 && qty >= ppb) {
1092
- let unitPriceFromBox = cBoxPrice / ppb;
1093
- return unitPriceFromBox * qty;
1094
- } else {
1095
- return cPrice * qty;
1096
  }
 
1097
  }
1098
 
1099
  function updateCartUI() {
@@ -1102,8 +1138,15 @@ CATALOG_TEMPLATE = '''
1102
  total += calculateItemPrice(cart[cKey]);
1103
  }
1104
 
 
 
 
 
 
 
 
1105
  const cartBar = document.getElementById('cartBar');
1106
- if (total > 0) {
1107
  cartBar.style.display = 'flex';
1108
  document.getElementById('cartTotalSum').innerText = Math.round(total * 100) / 100;
1109
  } else {
@@ -1133,6 +1176,11 @@ CATALOG_TEMPLATE = '''
1133
  }
1134
 
1135
  let itemTotal = calculateItemPrice(item);
 
 
 
 
 
1136
 
1137
  list.innerHTML += `
1138
  <div class="cart-item">
@@ -1140,15 +1188,18 @@ CATALOG_TEMPLATE = '''
1140
  ${nameDisplay}
1141
  <div style="font-size: 0.8rem; color: #00b894; margin-top:2px;">${formattedQty}</div>
1142
  </div>
1143
- <div style="display:flex; align-items:center; gap: 10px;">
1144
- <div class="cart-item-controls">
1145
- <button onclick="updateCart('${pId}', -1, null, true, '${cKey}', ${moq})"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
1146
- <input type="number" value="${item.quantity}" onchange="manualUpdateCartFromModal('${cKey}', this.value, ${moq})">
1147
- <button onclick="updateCart('${pId}', 1, null, true, '${cKey}', ${moq})"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
 
 
 
 
1148
  </div>
1149
- <button class="cart-item-delete" onclick="updateCart('${pId}', 0, 0, true, '${cKey}', ${moq})"><i class="fas fa-trash-alt"></i></button>
1150
  </div>
1151
- <div class="cart-item-price">${Math.round(itemTotal * 100) / 100} ${currency}</div>
1152
  </div>
1153
  `;
1154
  }
@@ -1174,11 +1225,14 @@ CATALOG_TEMPLATE = '''
1174
 
1175
  function submitOrder() {
1176
  const cartArray = Object.keys(cart).map(k => {
1177
- return { c_key: k, calculated_price: calculateItemPrice(cart[k]) / cart[k].quantity, ...cart[k] }
1178
  });
1179
  if(cartArray.length === 0) return;
1180
 
1181
- let orderData = { cart: cartArray, mode: mode, staff_id: staffId };
 
 
 
1182
 
1183
  if (mode === 'pos') {
1184
  const waEl = document.getElementById('custWhatsapp');
@@ -1499,6 +1553,8 @@ ORDER_TEMPLATE = '''
1499
 
1500
  #loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 999; justify-content: center; align-items: center; font-size: 2rem; color: var(--primary); }
1501
 
 
 
1502
  @media print {
1503
  body { background: #fff; padding: 0; }
1504
  .invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
@@ -1565,6 +1621,16 @@ ORDER_TEMPLATE = '''
1565
  </div>
1566
  </div>
1567
 
 
 
 
 
 
 
 
 
 
 
1568
  <div class="table-responsive">
1569
  <table>
1570
  <thead>
@@ -1573,7 +1639,7 @@ ORDER_TEMPLATE = '''
1573
  <th style="text-align: left;">Наименование</th>
1574
  <th>Фото</th>
1575
  <th>Кол-во</th>
1576
- <th>Цена</th>
1577
  <th>Сумма</th>
1578
  </tr>
1579
  </thead>
@@ -1592,6 +1658,9 @@ ORDER_TEMPLATE = '''
1592
  <div style="font-size: 0.85rem; color: #636e72;">Вариант: {{ item.variant_name }}</div>
1593
  {% endif %}
1594
  <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>
 
 
 
1595
  </td>
1596
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
1597
  <td style="text-align: center;">
@@ -1624,13 +1693,25 @@ ORDER_TEMPLATE = '''
1624
  {% endif %}
1625
  </div>
1626
  </td>
1627
- <td>{{ item.calculated_price | round(2) }}</td>
 
 
 
 
 
 
 
1628
  <td>{{ (item.calculated_price * item.quantity) | round(2) }}</td>
1629
  </tr>
1630
  {% endif %}
1631
  {% endfor %}
1632
  <tr class="total-row">
1633
- <td colspan="5" style="text-align: right; padding-right: 20px;">Итого:</td>
 
 
 
 
 
1634
  <td>{{ order.total_price }} {{ currency_code }}</td>
1635
  </tr>
1636
  </tbody>
@@ -1706,6 +1787,56 @@ ORDER_TEMPLATE = '''
1706
  document.getElementById('loadingOverlay').style.display = 'none';
1707
  });
1708
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1709
  {% endif %}
1710
  </script>
1711
  </body>
@@ -2130,7 +2261,10 @@ ADMIN_TEMPLATE = '''
2130
  <div style="display:flex; gap:10px; flex-wrap:wrap;">
2131
  {% if sys_mode != 'external' %}
2132
  <a href="/{{ env_id }}/reports" class="btn btn-primary" style="background:#8e44ad;"><i class="fas fa-chart-line"></i> Отчеты</a>
2133
- <a href="/{{ env_id }}/inventory" class="btn btn-primary" style="background:#27ae60;"><i class="fas fa-boxes"></i> Остатки</a>
 
 
 
2134
  {% endif %}
2135
 
2136
  {% if sys_mode == 'both' %}
@@ -2185,7 +2319,7 @@ ADMIN_TEMPLATE = '''
2185
  <div><b>Сумма:</b> {{ order.total_price }} {{ currency_code }}</div>
2186
  </div>
2187
  <div class="order-actions">
2188
- <a href="/{{ env_id }}/order/{{ order.id }}" class="btn btn-outline" target="_blank" style="padding: 5px 10px; font-size: 0.85rem;">Посмотреть</a>
2189
  <form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;">
2190
  <input type="hidden" name="action" value="confirm">
2191
  <button type="submit" class="btn btn-success" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-check"></i> Подтвердить</button>
@@ -2901,7 +3035,7 @@ REPORTS_TEMPLATE = '''
2901
  .filter-bar { background: var(--surface); padding: 15px 20px; border-radius: 12px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
2902
  .filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
2903
 
2904
- .tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; }
2905
  .tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; }
2906
  .tab:hover { background: #e9ecef; }
2907
  .tab.active { background: var(--primary); color: #fff; }
@@ -2925,7 +3059,7 @@ REPORTS_TEMPLATE = '''
2925
  <body>
2926
  <div class="container">
2927
  <div class="header">
2928
- <h1><i class="fas fa-chart-line"></i> Отчеты продаж</h1>
2929
  <a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
2930
  </div>
2931
 
@@ -2939,7 +3073,9 @@ REPORTS_TEMPLATE = '''
2939
 
2940
  <div class="tabs">
2941
  <div class="tab active" onclick="switchTab('general')">Общий отчет</div>
2942
- <div class="tab" onclick="switchTab('staff')">Отчет по сотрудникам</div>
 
 
2943
  </div>
2944
 
2945
  <div id="general" class="tab-content active">
@@ -2958,6 +3094,20 @@ REPORTS_TEMPLATE = '''
2958
  <i class="fas fa-shopping-cart icon"></i>
2959
  </div>
2960
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2961
  <div class="stat-card">
2962
  <div class="stat-card-inner">
2963
  <div class="title">Возвраты (сумма)</div>
@@ -2982,6 +3132,38 @@ REPORTS_TEMPLATE = '''
2982
  </div>
2983
  </div>
2984
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2985
  <div id="staff" class="tab-content">
2986
  <div class="table-container">
2987
  <h3>Выручка по сотрудникам</h3>
@@ -3039,24 +3221,34 @@ REPORTS_TEMPLATE = '''
3039
 
3040
  let totalRev = 0;
3041
  let totalRet = 0;
3042
- let ordersCount = filteredOrders.length;
 
3043
 
3044
  let staffSales = {};
3045
  let productSales = {};
 
 
3046
 
3047
  filteredOrders.forEach(o => {
3048
  if(o.status === 'returned') {
3049
  totalRet += o.total_price;
3050
  } else {
3051
  totalRev += o.total_price;
 
3052
 
3053
  const staff = o.staff_name || 'Онлайн (Без сотрудника)';
3054
  if(!staffSales[staff]) staffSales[staff] = { orders: 0, sum: 0 };
3055
  staffSales[staff].orders += 1;
3056
  staffSales[staff].sum += o.total_price;
3057
 
 
 
 
 
 
3058
  o.cart.forEach(item => {
3059
  if(item.quantity > 0) {
 
3060
  let pName = item.name;
3061
  if(item.variant_name) pName += ` (${item.variant_name})`;
3062
 
@@ -3065,6 +3257,11 @@ REPORTS_TEMPLATE = '''
3065
 
3066
  let itemPrice = parseFloat(item.calculated_price) || parseFloat(item.price);
3067
  productSales[pName].sum += (itemPrice * item.quantity);
 
 
 
 
 
3068
  }
3069
  });
3070
  }
@@ -3073,22 +3270,25 @@ REPORTS_TEMPLATE = '''
3073
  document.getElementById('totalRevenue').innerText = totalRev.toLocaleString() + ' {{ currency_code }}';
3074
  document.getElementById('totalOrders').innerText = ordersCount;
3075
  document.getElementById('totalReturns').innerText = totalRet.toLocaleString() + ' {{ currency_code }}';
 
 
 
 
3076
 
3077
  renderTopProducts(productSales);
3078
  renderStaffTable(staffSales);
 
 
3079
  }
3080
 
3081
  function renderTopProducts(data) {
3082
  const tbody = document.getElementById('topProductsTable');
3083
  tbody.innerHTML = '';
3084
-
3085
  const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 15);
3086
-
3087
  if(sorted.length === 0) {
3088
  tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3089
  return;
3090
  }
3091
-
3092
  sorted.forEach(p => {
3093
  tbody.innerHTML += `
3094
  <tr>
@@ -3103,14 +3303,11 @@ REPORTS_TEMPLATE = '''
3103
  function renderStaffTable(data) {
3104
  const tbody = document.getElementById('staffSalesTable');
3105
  tbody.innerHTML = '';
3106
-
3107
  const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
3108
-
3109
  if(sorted.length === 0) {
3110
  tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3111
  return;
3112
  }
3113
-
3114
  sorted.forEach(s => {
3115
  tbody.innerHTML += `
3116
  <tr>
@@ -3122,6 +3319,44 @@ REPORTS_TEMPLATE = '''
3122
  });
3123
  }
3124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3125
  setMonthDates();
3126
  </script>
3127
  </body>
@@ -3149,7 +3384,7 @@ INVENTORY_TEMPLATE = '''
3149
  .search-bar input { width: 100%; padding: 10px 10px 10px 40px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 1rem; }
3150
 
3151
  .tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; }
3152
- .tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; }
3153
  .tab:hover { background: #e9ecef; }
3154
  .tab.active { background: var(--primary); color: #fff; }
3155
  .tab-content { display: none; }
@@ -3168,6 +3403,7 @@ INVENTORY_TEMPLATE = '''
3168
  .btn-sub { background: var(--danger); }
3169
  .badge-type-add { background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
3170
  .badge-type-sub { background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
 
3171
  </style>
3172
  </head>
3173
  <body>
@@ -3179,6 +3415,7 @@ INVENTORY_TEMPLATE = '''
3179
 
3180
  <div class="tabs">
3181
  <div class="tab active" onclick="switchTab('current')">Текущие остатки</div>
 
3182
  <div class="tab" onclick="switchTab('history')">История операций</div>
3183
  </div>
3184
 
@@ -3237,6 +3474,33 @@ INVENTORY_TEMPLATE = '''
3237
  </div>
3238
  </div>
3239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3240
  <div id="history" class="tab-content">
3241
  <div class="table-container">
3242
  <table>
@@ -3594,6 +3858,9 @@ def create_order(env_id):
3594
 
3595
  mode = order_data.get('mode', 'online')
3596
  staff_id = order_data.get('staff_id', '')
 
 
 
3597
 
3598
  staff_name = ''
3599
  staff_whatsapp = ''
@@ -3613,6 +3880,8 @@ def create_order(env_id):
3613
  customer_zip = order_data.get('customer_zip', '')
3614
  customer_whatsapp = order_data.get('customer_whatsapp', '')
3615
 
 
 
3616
  processed_cart = []
3617
  for item in cart_items:
3618
  processed_cart.append({
@@ -3625,6 +3894,8 @@ def create_order(env_id):
3625
  "pieces_per_box": int(item.get('pieces_per_box', 1)) if str(item.get('pieces_per_box', 1)).strip() != "" else 1,
3626
  "variant_name": item.get('variant_name', ''),
3627
  "variant_idx": item.get('variant_idx', -1),
 
 
3628
  "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
3629
  })
3630
 
@@ -3644,6 +3915,7 @@ def create_order(env_id):
3644
  "customer_address": customer_address,
3645
  "customer_zip": customer_zip,
3646
  "customer_whatsapp": customer_whatsapp,
 
3647
  "assembled": {}
3648
  }
3649
 
@@ -3699,24 +3971,31 @@ def edit_order(env_id, order_id):
3699
  return jsonify({"success": False, "error": "Can only edit pending orders"}), 400
3700
 
3701
  req_data = request.get_json()
3702
- c_key = req_data.get('c_key')
3703
- change = req_data.get('change', 0)
3704
- exact_qty = req_data.get('exact_qty')
3705
- remove = req_data.get('remove', False)
3706
-
3707
- for item in order['cart']:
3708
- if item.get('c_key') == c_key:
3709
- if remove:
3710
- order['cart'].remove(item)
3711
- else:
3712
- if exact_qty is not None:
3713
- item['quantity'] = int(exact_qty)
3714
- else:
3715
- item['quantity'] += change
3716
-
3717
- if item['quantity'] <= 0:
3718
  order['cart'].remove(item)
3719
- break
 
 
 
 
 
 
 
 
 
 
3720
 
3721
  update_order_totals(order, data['settings'].get('business_type', 'mixed'))
3722
  save_env_data(env_id, data)
@@ -3813,11 +4092,14 @@ def inventory(env_id):
3813
  if settings.get('system_mode', 'both') == 'external':
3814
  return redirect(url_for('admin', env_id=env_id))
3815
 
 
 
3816
  return render_template_string(
3817
  INVENTORY_TEMPLATE,
3818
  env_id=env_id,
3819
  products=data.get('products', []),
3820
- history=data.get('inventory_history', [])
 
3821
  )
3822
 
3823
  @app.route('/<env_id>/api/inventory', methods=['POST'])
@@ -3837,11 +4119,11 @@ def api_inventory(env_id):
3837
  if vidx != -1 and vidx < len(p.get('variants', [])):
3838
  v_name = p['variants'][vidx]['name']
3839
  curr = p['variants'][vidx].get('stock', 0)
3840
- curr = int(curr) if str(curr).strip() != "" and curr is not None else 0
3841
  p['variants'][vidx]['stock'] = curr + qty if is_add else curr - qty
3842
  else:
3843
  curr = p.get('stock', 0)
3844
- curr = int(curr) if str(curr).strip() != "" and curr is not None else 0
3845
  p['stock'] = curr + qty if is_add else curr - qty
3846
 
3847
  data['inventory_history'].append({
@@ -3873,6 +4155,7 @@ def admin(env_id):
3873
  pending_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
3874
 
3875
  unassembled_count = len([o for o in orders.values() if not is_order_fully_assembled(o)])
 
3876
 
3877
  if request.method == 'POST':
3878
  action = request.form.get('action')
@@ -4195,7 +4478,8 @@ def admin(env_id):
4195
  settings=settings,
4196
  staff=staff,
4197
  pending_orders=pending_orders,
4198
- unassembled_count=unassembled_count
 
4199
  )
4200
 
4201
  @app.route('/<env_id>/force_upload', methods=['POST'])
 
213
  if 'status' not in order: order['status'] = 'confirmed'; changed = True
214
  if 'staff_name' not in order: order['staff_name'] = ''; changed = True
215
  if 'assembled' not in order: order['assembled'] = {}; changed = True
216
+ if 'global_discount' not in order: order['global_discount'] = 0; changed = True
217
+ for item in order.get('cart', []):
218
+ if 'discount' not in item: item['discount'] = 0; changed = True
219
+ if 'category' not in item: item['category'] = 'Без категории'; changed = True
220
 
221
  if changed or not os.path.exists(DATA_FILE):
222
  try:
 
279
 
280
  def update_order_totals(order, business_type):
281
  total = 0
282
+ global_discount = float(order.get('global_discount', 0))
283
  for i in order['cart']:
284
  qty = int(i.get('quantity', 0))
285
  if qty <= 0:
 
286
  continue
287
  ppb = int(i.get('pieces_per_box', 1))
288
  c_price = float(i.get('price', 0))
289
  c_box_price = float(i.get('cart_box_price', 0))
290
+ item_discount = float(i.get('discount', 0))
291
 
292
  if business_type in ['mixed', 'wholesale'] and c_box_price > 0 and ppb > 1 and qty >= ppb:
293
+ base_price = c_box_price / ppb
294
  else:
295
+ base_price = c_price
296
 
297
+ discounted_price = base_price * (1 - item_discount / 100.0)
298
+ item_total = discounted_price * qty
299
+ i['calculated_price'] = round(discounted_price, 2)
300
  total += item_total
301
+
302
+ total = total * (1 - global_discount / 100.0)
303
  order['total_price'] = round(total, 2)
304
 
305
  def is_order_fully_assembled(order):
 
345
  p['stock'] = int(current_s) + return_qty
346
  break
347
 
348
+ def get_low_stock_items(products):
349
+ low_stock = []
350
+ for p in products:
351
+ if p.get('variants'):
352
+ for vidx, v in enumerate(p['variants']):
353
+ s = v.get('stock')
354
+ if s != "" and s is not None and str(s).lstrip('-').isdigit() and int(s) < 100:
355
+ low_stock.append({"name": p['name'], "variant": v.get('name'), "stock": int(s), "category": p.get('category', '')})
356
+ else:
357
+ s = p.get('stock')
358
+ if s != "" and s is not None and str(s).lstrip('-').isdigit() and int(s) < 100:
359
+ low_stock.append({"name": p['name'], "variant": "", "stock": int(s), "category": p.get('category', '')})
360
+ return low_stock
361
 
362
  LANDING_PAGE_TEMPLATE = '''
363
  <!DOCTYPE html>
 
715
 
716
  <div class="customer-form">
717
  {% if mode == 'pos' %}
718
+ <div style="margin-top: 5px; margin-bottom: 15px; background: var(--bg); padding: 10px; border-radius: 12px; border: 1px solid var(--border);">
719
+ <label style="font-size: 0.9rem; font-weight: 600; display:block; margin-bottom:5px;">Общая скидка на чек (%)</label>
720
+ <input type="number" id="globalDiscountVal" value="0" min="0" max="100" onchange="updateCartUI()" style="width: 100%; border:none; background:var(--surface); padding:10px; border-radius:8px; font-weight:600; outline:none;">
721
+ </div>
722
  <input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
723
  <input type="text" id="custWhatsapp" placeholder="WhatsApp клиента (напр. +77001234567) необязательно">
724
  {% else %}
 
1050
  }
1051
  vName = p.variants[varIdx].name;
1052
  }
1053
+ cart[cKey] = { ...p, quantity: 0, cart_price: price, cart_box_price: bPrice, pieces_per_box: pPpb, variant_name: vName, variant_idx: varIdx, discount: 0 };
1054
  }
1055
 
1056
  let currentQty = cart[cKey].quantity;
 
1107
  const pId = cKey.split('___')[0];
1108
  updateCart(pId, 0, num, true, cKey, moq);
1109
  }
1110
+
1111
+ function updateItemDiscount(cKey, val) {
1112
+ let num = parseFloat(val);
1113
+ if(isNaN(num) || num < 0) num = 0;
1114
+ if(num > 100) num = 100;
1115
+ if(cart[cKey]) {
1116
+ cart[cKey].discount = num;
1117
+ updateCartUI();
1118
+ }
1119
+ }
1120
 
1121
  function calculateItemPrice(item) {
1122
  let ppb = parseInt(item.pieces_per_box) || 1;
1123
  let qty = item.quantity;
1124
  let cBoxPrice = parseFloat(item.cart_box_price) || 0;
1125
  let cPrice = parseFloat(item.cart_price) || 0;
1126
+ let disc = parseFloat(item.discount) || 0;
1127
 
1128
+ let unit = cPrice;
1129
  if ((businessType === 'mixed' || businessType === 'wholesale') && cBoxPrice > 0 && ppb > 1 && qty >= ppb) {
1130
+ unit = cBoxPrice / ppb;
 
 
 
1131
  }
1132
+ return unit * (1 - disc/100) * qty;
1133
  }
1134
 
1135
  function updateCartUI() {
 
1138
  total += calculateItemPrice(cart[cKey]);
1139
  }
1140
 
1141
+ let globalDiscInput = document.getElementById('globalDiscountVal');
1142
+ let globalDisc = globalDiscInput ? parseFloat(globalDiscInput.value) || 0 : 0;
1143
+ if(globalDisc > 100) globalDisc = 100;
1144
+ if(globalDisc < 0) globalDisc = 0;
1145
+
1146
+ total = total * (1 - globalDisc/100);
1147
+
1148
  const cartBar = document.getElementById('cartBar');
1149
+ if (total > 0 || Object.keys(cart).length > 0) {
1150
  cartBar.style.display = 'flex';
1151
  document.getElementById('cartTotalSum').innerText = Math.round(total * 100) / 100;
1152
  } else {
 
1176
  }
1177
 
1178
  let itemTotal = calculateItemPrice(item);
1179
+
1180
+ let discountHtml = '';
1181
+ if(mode === 'pos') {
1182
+ discountHtml = `<input type="number" style="width: 50px; 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" max="100" title="Скидка % на позицию"> %`;
1183
+ }
1184
 
1185
  list.innerHTML += `
1186
  <div class="cart-item">
 
1188
  ${nameDisplay}
1189
  <div style="font-size: 0.8rem; color: #00b894; margin-top:2px;">${formattedQty}</div>
1190
  </div>
1191
+ <div style="display:flex; flex-direction:column; align-items:flex-end; gap:5px;">
1192
+ <div style="display:flex; align-items:center; gap: 10px;">
1193
+ ${discountHtml}
1194
+ <div class="cart-item-controls">
1195
+ <button onclick="updateCart('${pId}', -1, null, true, '${cKey}', ${moq})"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
1196
+ <input type="number" value="${item.quantity}" onchange="manualUpdateCartFromModal('${cKey}', this.value, ${moq})">
1197
+ <button onclick="updateCart('${pId}', 1, null, true, '${cKey}', ${moq})"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
1198
+ </div>
1199
+ <button class="cart-item-delete" onclick="updateCart('${pId}', 0, 0, true, '${cKey}', ${moq})"><i class="fas fa-trash-alt"></i></button>
1200
  </div>
1201
+ <div class="cart-item-price">${Math.round(itemTotal * 100) / 100} ${currency}</div>
1202
  </div>
 
1203
  </div>
1204
  `;
1205
  }
 
1225
 
1226
  function submitOrder() {
1227
  const cartArray = Object.keys(cart).map(k => {
1228
+ return { c_key: k, calculated_price: calculateItemPrice(cart[k]) / cart[k].quantity, discount: cart[k].discount || 0, ...cart[k] }
1229
  });
1230
  if(cartArray.length === 0) return;
1231
 
1232
+ let globalDiscInput = document.getElementById('globalDiscountVal');
1233
+ let globalDisc = globalDiscInput ? parseFloat(globalDiscInput.value) || 0 : 0;
1234
+
1235
+ let orderData = { cart: cartArray, mode: mode, staff_id: staffId, global_discount: globalDisc };
1236
 
1237
  if (mode === 'pos') {
1238
  const waEl = document.getElementById('custWhatsapp');
 
1553
 
1554
  #loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 999; justify-content: center; align-items: center; font-size: 2rem; color: var(--primary); }
1555
 
1556
+ .discount-input-small { width: 50px; font-size: 0.8rem; padding: 4px; border-radius: 4px; border: 1px solid #ccc; text-align: center; margin-top: 5px; outline: none; }
1557
+
1558
  @media print {
1559
  body { background: #fff; padding: 0; }
1560
  .invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
 
1621
  </div>
1622
  </div>
1623
 
1624
+ {% if order.status == 'pending' %}
1625
+ <div class="screen-only" style="margin-bottom: 20px; background: #fafafa; padding: 15px; border-radius: 8px; border: 1px solid var(--border);">
1626
+ <div style="font-weight:600; margin-bottom:5px;">Общая скидка на заказ (%)</div>
1627
+ <div style="display:flex; gap:10px; align-items:center;">
1628
+ <input type="number" id="globalDiscountInput" value="{{ order.global_discount }}" min="0" max="100" style="padding: 8px; border-radius: 6px; border: 1px solid #ccc; width: 100px;">
1629
+ <button onclick="applyGlobalDiscount()" style="padding: 8px 15px; background: #0984e3; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">Применить</button>
1630
+ </div>
1631
+ </div>
1632
+ {% endif %}
1633
+
1634
  <div class="table-responsive">
1635
  <table>
1636
  <thead>
 
1639
  <th style="text-align: left;">Наименование</th>
1640
  <th>Фото</th>
1641
  <th>Кол-во</th>
1642
+ <th>Цена со скидкой</th>
1643
  <th>Сумма</th>
1644
  </tr>
1645
  </thead>
 
1658
  <div style="font-size: 0.85rem; color: #636e72;">Вариант: {{ item.variant_name }}</div>
1659
  {% endif %}
1660
  <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>
1661
+ {% if item.discount and item.discount > 0 %}
1662
+ <div style="font-size: 0.8rem; color: #e17055; margin-top: 2px;">Скидка: {{ item.discount }}%</div>
1663
+ {% endif %}
1664
  </td>
1665
  <td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
1666
  <td style="text-align: center;">
 
1693
  {% endif %}
1694
  </div>
1695
  </td>
1696
+ <td>
1697
+ {{ item.calculated_price | round(2) }}
1698
+ {% if order.status == 'pending' %}
1699
+ <div class="screen-only">
1700
+ <input type="number" class="discount-input-small" title="Скидка %" placeholder="% ск" value="{{ item.discount }}" onchange="updateItemDiscount('{{ item.c_key }}', this.value)"> %
1701
+ </div>
1702
+ {% endif %}
1703
+ </td>
1704
  <td>{{ (item.calculated_price * item.quantity) | round(2) }}</td>
1705
  </tr>
1706
  {% endif %}
1707
  {% endfor %}
1708
  <tr class="total-row">
1709
+ <td colspan="5" style="text-align: right; padding-right: 20px;">
1710
+ {% if order.global_discount > 0 %}
1711
+ <div style="color:#e17055; font-size:0.9rem; margin-bottom:5px;">Применена общая скидка: {{ order.global_discount }}%</div>
1712
+ {% endif %}
1713
+ Итого:
1714
+ </td>
1715
  <td>{{ order.total_price }} {{ currency_code }}</td>
1716
  </tr>
1717
  </tbody>
 
1787
  document.getElementById('loadingOverlay').style.display = 'none';
1788
  });
1789
  }
1790
+
1791
+ function updateItemDiscount(cKey, val) {
1792
+ let num = parseFloat(val);
1793
+ if(isNaN(num) || num < 0) num = 0;
1794
+ if(num > 100) num = 100;
1795
+ document.getElementById('loadingOverlay').style.display = 'flex';
1796
+ fetch(`/${envId}/edit_order/{{ order.id }}`, {
1797
+ method: 'POST',
1798
+ headers: { 'Content-Type': 'application/json' },
1799
+ body: JSON.stringify({ c_key: cKey, item_discount: num })
1800
+ })
1801
+ .then(r => r.json())
1802
+ .then(data => {
1803
+ if(data.success) {
1804
+ window.location.reload();
1805
+ } else {
1806
+ alert('Ошибка обновления скидки');
1807
+ document.getElementById('loadingOverlay').style.display = 'none';
1808
+ }
1809
+ })
1810
+ .catch(() => {
1811
+ alert('Произошла ошибка');
1812
+ document.getElementById('loadingOverlay').style.display = 'none';
1813
+ });
1814
+ }
1815
+
1816
+ function applyGlobalDiscount() {
1817
+ let val = parseFloat(document.getElementById('globalDiscountInput').value);
1818
+ if(isNaN(val) || val < 0) val = 0;
1819
+ if(val > 100) val = 100;
1820
+ document.getElementById('loadingOverlay').style.display = 'flex';
1821
+ fetch(`/${envId}/edit_order/{{ order.id }}`, {
1822
+ method: 'POST',
1823
+ headers: { 'Content-Type': 'application/json' },
1824
+ body: JSON.stringify({ global_discount: val })
1825
+ })
1826
+ .then(r => r.json())
1827
+ .then(data => {
1828
+ if(data.success) {
1829
+ window.location.reload();
1830
+ } else {
1831
+ alert('Ошибка обновления общей скидки');
1832
+ document.getElementById('loadingOverlay').style.display = 'none';
1833
+ }
1834
+ })
1835
+ .catch(() => {
1836
+ alert('Произошла ошибка');
1837
+ document.getElementById('loadingOverlay').style.display = 'none';
1838
+ });
1839
+ }
1840
  {% endif %}
1841
  </script>
1842
  </body>
 
2261
  <div style="display:flex; gap:10px; flex-wrap:wrap;">
2262
  {% if sys_mode != 'external' %}
2263
  <a href="/{{ env_id }}/reports" class="btn btn-primary" style="background:#8e44ad;"><i class="fas fa-chart-line"></i> Отчеты</a>
2264
+ <a href="/{{ env_id }}/inventory" class="btn btn-primary" style="background:#27ae60;">
2265
+ <i class="fas fa-boxes"></i> Остатки
2266
+ {% if low_stock_count > 0 %}<span class="badge" style="background:#e17055;">{{ low_stock_count }}</span>{% endif %}
2267
+ </a>
2268
  {% endif %}
2269
 
2270
  {% if sys_mode == 'both' %}
 
2319
  <div><b>Сумма:</b> {{ order.total_price }} {{ currency_code }}</div>
2320
  </div>
2321
  <div class="order-actions">
2322
+ <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-edit"></i> Редактировать</a>
2323
  <form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;">
2324
  <input type="hidden" name="action" value="confirm">
2325
  <button type="submit" class="btn btn-success" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-check"></i> Подтвердить</button>
 
3035
  .filter-bar { background: var(--surface); padding: 15px 20px; border-radius: 12px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
3036
  .filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
3037
 
3038
+ .tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; flex-wrap: wrap; }
3039
  .tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; }
3040
  .tab:hover { background: #e9ecef; }
3041
  .tab.active { background: var(--primary); color: #fff; }
 
3059
  <body>
3060
  <div class="container">
3061
  <div class="header">
3062
+ <h1><i class="fas fa-chart-line"></i> Расширенные Отчеты</h1>
3063
  <a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
3064
  </div>
3065
 
 
3073
 
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>
3080
 
3081
  <div id="general" class="tab-content active">
 
3094
  <i class="fas fa-shopping-cart icon"></i>
3095
  </div>
3096
  </div>
3097
+ <div class="stat-card">
3098
+ <div class="stat-card-inner">
3099
+ <div class="title">Средний чек (AOV)</div>
3100
+ <div class="value" id="avgOrderValue">0</div>
3101
+ <i class="fas fa-receipt icon"></i>
3102
+ </div>
3103
+ </div>
3104
+ <div class="stat-card">
3105
+ <div class="stat-card-inner">
3106
+ <div class="title">Продано товаров (шт)</div>
3107
+ <div class="value" id="totalItemsSold">0</div>
3108
+ <i class="fas fa-box icon"></i>
3109
+ </div>
3110
+ </div>
3111
  <div class="stat-card">
3112
  <div class="stat-card-inner">
3113
  <div class="title">Возвраты (сумма)</div>
 
3132
  </div>
3133
  </div>
3134
 
3135
+ <div id="daily" class="tab-content">
3136
+ <div class="table-container">
3137
+ <h3>Продажи по дням</h3>
3138
+ <table>
3139
+ <thead>
3140
+ <tr>
3141
+ <th>Дата</th>
3142
+ <th>Кол-во заказов</th>
3143
+ <th>Выручка ({{ currency_code }})</th>
3144
+ </tr>
3145
+ </thead>
3146
+ <tbody id="dailySalesTable"></tbody>
3147
+ </table>
3148
+ </div>
3149
+ </div>
3150
+
3151
+ <div id="category" class="tab-content">
3152
+ <div class="table-container">
3153
+ <h3>Продажи по категориям</h3>
3154
+ <table>
3155
+ <thead>
3156
+ <tr>
3157
+ <th>Категория</th>
3158
+ <th>Кол-во (шт)</th>
3159
+ <th>Выручка ({{ currency_code }})</th>
3160
+ </tr>
3161
+ </thead>
3162
+ <tbody id="categorySalesTable"></tbody>
3163
+ </table>
3164
+ </div>
3165
+ </div>
3166
+
3167
  <div id="staff" class="tab-content">
3168
  <div class="table-container">
3169
  <h3>Выручка по сотрудникам</h3>
 
3221
 
3222
  let totalRev = 0;
3223
  let totalRet = 0;
3224
+ let totalItems = 0;
3225
+ let ordersCount = 0;
3226
 
3227
  let staffSales = {};
3228
  let productSales = {};
3229
+ let dailySales = {};
3230
+ let categorySales = {};
3231
 
3232
  filteredOrders.forEach(o => {
3233
  if(o.status === 'returned') {
3234
  totalRet += o.total_price;
3235
  } else {
3236
  totalRev += o.total_price;
3237
+ ordersCount++;
3238
 
3239
  const staff = o.staff_name || 'Онлайн (Без сотрудника)';
3240
  if(!staffSales[staff]) staffSales[staff] = { orders: 0, sum: 0 };
3241
  staffSales[staff].orders += 1;
3242
  staffSales[staff].sum += o.total_price;
3243
 
3244
+ const dateStr = o.created_at.split(' ')[0];
3245
+ if(!dailySales[dateStr]) dailySales[dateStr] = { orders: 0, sum: 0 };
3246
+ dailySales[dateStr].orders += 1;
3247
+ dailySales[dateStr].sum += o.total_price;
3248
+
3249
  o.cart.forEach(item => {
3250
  if(item.quantity > 0) {
3251
+ totalItems += item.quantity;
3252
  let pName = item.name;
3253
  if(item.variant_name) pName += ` (${item.variant_name})`;
3254
 
 
3257
 
3258
  let itemPrice = parseFloat(item.calculated_price) || parseFloat(item.price);
3259
  productSales[pName].sum += (itemPrice * item.quantity);
3260
+
3261
+ let catName = item.category || 'Без категории';
3262
+ if(!categorySales[catName]) categorySales[catName] = { qty: 0, sum: 0 };
3263
+ categorySales[catName].qty += item.quantity;
3264
+ categorySales[catName].sum += (itemPrice * item.quantity);
3265
  }
3266
  });
3267
  }
 
3270
  document.getElementById('totalRevenue').innerText = totalRev.toLocaleString() + ' {{ currency_code }}';
3271
  document.getElementById('totalOrders').innerText = ordersCount;
3272
  document.getElementById('totalReturns').innerText = totalRet.toLocaleString() + ' {{ currency_code }}';
3273
+ document.getElementById('totalItemsSold').innerText = totalItems.toLocaleString();
3274
+
3275
+ let aov = ordersCount > 0 ? (totalRev / ordersCount) : 0;
3276
+ document.getElementById('avgOrderValue').innerText = Math.round(aov).toLocaleString() + ' {{ currency_code }}';
3277
 
3278
  renderTopProducts(productSales);
3279
  renderStaffTable(staffSales);
3280
+ renderDailyTable(dailySales);
3281
+ renderCategoryTable(categorySales);
3282
  }
3283
 
3284
  function renderTopProducts(data) {
3285
  const tbody = document.getElementById('topProductsTable');
3286
  tbody.innerHTML = '';
 
3287
  const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 15);
 
3288
  if(sorted.length === 0) {
3289
  tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3290
  return;
3291
  }
 
3292
  sorted.forEach(p => {
3293
  tbody.innerHTML += `
3294
  <tr>
 
3303
  function renderStaffTable(data) {
3304
  const tbody = document.getElementById('staffSalesTable');
3305
  tbody.innerHTML = '';
 
3306
  const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
 
3307
  if(sorted.length === 0) {
3308
  tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3309
  return;
3310
  }
 
3311
  sorted.forEach(s => {
3312
  tbody.innerHTML += `
3313
  <tr>
 
3319
  });
3320
  }
3321
 
3322
+ function renderDailyTable(data) {
3323
+ const tbody = document.getElementById('dailySalesTable');
3324
+ tbody.innerHTML = '';
3325
+ const sorted = Object.keys(data).sort((a,b) => new Date(b) - new Date(a));
3326
+ if(sorted.length === 0) {
3327
+ tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3328
+ return;
3329
+ }
3330
+ sorted.forEach(d => {
3331
+ tbody.innerHTML += `
3332
+ <tr>
3333
+ <td style="font-weight:500;">${d}</td>
3334
+ <td>${data[d].orders}</td>
3335
+ <td>${Math.round(data[d].sum).toLocaleString()}</td>
3336
+ </tr>
3337
+ `;
3338
+ });
3339
+ }
3340
+
3341
+ function renderCategoryTable(data) {
3342
+ const tbody = document.getElementById('categorySalesTable');
3343
+ tbody.innerHTML = '';
3344
+ const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
3345
+ if(sorted.length === 0) {
3346
+ tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
3347
+ return;
3348
+ }
3349
+ sorted.forEach(c => {
3350
+ tbody.innerHTML += `
3351
+ <tr>
3352
+ <td style="font-weight:500;">${c}</td>
3353
+ <td>${data[c].qty}</td>
3354
+ <td>${Math.round(data[c].sum).toLocaleString()}</td>
3355
+ </tr>
3356
+ `;
3357
+ });
3358
+ }
3359
+
3360
  setMonthDates();
3361
  </script>
3362
  </body>
 
3384
  .search-bar input { width: 100%; padding: 10px 10px 10px 40px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 1rem; }
3385
 
3386
  .tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; }
3387
+ .tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; display:flex; align-items:center; gap:8px; }
3388
  .tab:hover { background: #e9ecef; }
3389
  .tab.active { background: var(--primary); color: #fff; }
3390
  .tab-content { display: none; }
 
3403
  .btn-sub { background: var(--danger); }
3404
  .badge-type-add { background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
3405
  .badge-type-sub { background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
3406
+ .badge-danger { background: var(--danger); color: white; padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; }
3407
  </style>
3408
  </head>
3409
  <body>
 
3415
 
3416
  <div class="tabs">
3417
  <div class="tab active" onclick="switchTab('current')">Текущие остатки</div>
3418
+ <div class="tab" onclick="switchTab('low')">Заканчивающиеся <span class="badge-danger">{{ low_stock_items|length }}</span></div>
3419
  <div class="tab" onclick="switchTab('history')">История операций</div>
3420
  </div>
3421
 
 
3474
  </div>
3475
  </div>
3476
 
3477
+ <div id="low" class="tab-content">
3478
+ <div class="table-container">
3479
+ <table>
3480
+ <thead>
3481
+ <tr>
3482
+ <th>Товар</th>
3483
+ <th>Вариант</th>
3484
+ <th>Категория</th>
3485
+ <th>Остаток</th>
3486
+ </tr>
3487
+ </thead>
3488
+ <tbody>
3489
+ {% for item in low_stock_items %}
3490
+ <tr>
3491
+ <td><strong>{{ item.name }}</strong></td>
3492
+ <td>{{ item.variant }}</td>
3493
+ <td>{{ item.category }}</td>
3494
+ <td style="color:var(--danger); font-weight:bold;">{{ item.stock }}</td>
3495
+ </tr>
3496
+ {% else %}
3497
+ <tr><td colspan="4" style="text-align:center;">Нет заканчивающихся товаров</td></tr>
3498
+ {% endfor %}
3499
+ </tbody>
3500
+ </table>
3501
+ </div>
3502
+ </div>
3503
+
3504
  <div id="history" class="tab-content">
3505
  <div class="table-container">
3506
  <table>
 
3858
 
3859
  mode = order_data.get('mode', 'online')
3860
  staff_id = order_data.get('staff_id', '')
3861
+ global_discount = float(order_data.get('global_discount', 0))
3862
+ if global_discount > 100: global_discount = 100
3863
+ if global_discount < 0: global_discount = 0
3864
 
3865
  staff_name = ''
3866
  staff_whatsapp = ''
 
3880
  customer_zip = order_data.get('customer_zip', '')
3881
  customer_whatsapp = order_data.get('customer_whatsapp', '')
3882
 
3883
+ product_dict = {p['product_id']: p.get('category', 'Без категории') for p in data.get('products', [])}
3884
+
3885
  processed_cart = []
3886
  for item in cart_items:
3887
  processed_cart.append({
 
3894
  "pieces_per_box": int(item.get('pieces_per_box', 1)) if str(item.get('pieces_per_box', 1)).strip() != "" else 1,
3895
  "variant_name": item.get('variant_name', ''),
3896
  "variant_idx": item.get('variant_idx', -1),
3897
+ "discount": float(item.get('discount', 0)),
3898
+ "category": product_dict.get(item.get('product_id'), 'Без категории'),
3899
  "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
3900
  })
3901
 
 
3915
  "customer_address": customer_address,
3916
  "customer_zip": customer_zip,
3917
  "customer_whatsapp": customer_whatsapp,
3918
+ "global_discount": global_discount,
3919
  "assembled": {}
3920
  }
3921
 
 
3971
  return jsonify({"success": False, "error": "Can only edit pending orders"}), 400
3972
 
3973
  req_data = request.get_json()
3974
+
3975
+ if 'global_discount' in req_data:
3976
+ order['global_discount'] = float(req_data.get('global_discount', 0))
3977
+ else:
3978
+ c_key = req_data.get('c_key')
3979
+ change = req_data.get('change', 0)
3980
+ exact_qty = req_data.get('exact_qty')
3981
+ remove = req_data.get('remove', False)
3982
+ item_discount = req_data.get('item_discount')
3983
+
3984
+ for item in order['cart']:
3985
+ if item.get('c_key') == c_key:
3986
+ if remove:
 
 
 
3987
  order['cart'].remove(item)
3988
+ elif item_discount is not None:
3989
+ item['discount'] = float(item_discount)
3990
+ else:
3991
+ if exact_qty is not None:
3992
+ item['quantity'] = int(exact_qty)
3993
+ else:
3994
+ item['quantity'] += change
3995
+
3996
+ if item['quantity'] <= 0:
3997
+ order['cart'].remove(item)
3998
+ break
3999
 
4000
  update_order_totals(order, data['settings'].get('business_type', 'mixed'))
4001
  save_env_data(env_id, data)
 
4092
  if settings.get('system_mode', 'both') == 'external':
4093
  return redirect(url_for('admin', env_id=env_id))
4094
 
4095
+ low_stock_items = get_low_stock_items(data.get('products', []))
4096
+
4097
  return render_template_string(
4098
  INVENTORY_TEMPLATE,
4099
  env_id=env_id,
4100
  products=data.get('products', []),
4101
+ history=data.get('inventory_history', []),
4102
+ low_stock_items=low_stock_items
4103
  )
4104
 
4105
  @app.route('/<env_id>/api/inventory', methods=['POST'])
 
4119
  if vidx != -1 and vidx < len(p.get('variants', [])):
4120
  v_name = p['variants'][vidx]['name']
4121
  curr = p['variants'][vidx].get('stock', 0)
4122
+ curr = int(curr) if str(curr).lstrip('-').strip() != "" and curr is not None else 0
4123
  p['variants'][vidx]['stock'] = curr + qty if is_add else curr - qty
4124
  else:
4125
  curr = p.get('stock', 0)
4126
+ curr = int(curr) if str(curr).lstrip('-').strip() != "" and curr is not None else 0
4127
  p['stock'] = curr + qty if is_add else curr - qty
4128
 
4129
  data['inventory_history'].append({
 
4155
  pending_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
4156
 
4157
  unassembled_count = len([o for o in orders.values() if not is_order_fully_assembled(o)])
4158
+ low_stock_count = len(get_low_stock_items(products))
4159
 
4160
  if request.method == 'POST':
4161
  action = request.form.get('action')
 
4478
  settings=settings,
4479
  staff=staff,
4480
  pending_orders=pending_orders,
4481
+ unassembled_count=unassembled_count,
4482
+ low_stock_count=low_stock_count
4483
  )
4484
 
4485
  @app.route('/<env_id>/force_upload', methods=['POST'])