Update app.py
Browse files
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 |
-
|
| 289 |
else:
|
| 290 |
-
|
| 291 |
|
| 292 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|
| 1144 |
-
<div
|
| 1145 |
-
|
| 1146 |
-
<
|
| 1147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
</div>
|
| 1149 |
-
<
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;">
|
|
|
|
|
|
|
|
|
|
| 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;">
|
| 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> Отчеты
|
| 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('
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 3703 |
-
|
| 3704 |
-
|
| 3705 |
-
|
| 3706 |
-
|
| 3707 |
-
|
| 3708 |
-
|
| 3709 |
-
|
| 3710 |
-
|
| 3711 |
-
|
| 3712 |
-
|
| 3713 |
-
|
| 3714 |
-
|
| 3715 |
-
item['quantity'] += change
|
| 3716 |
-
|
| 3717 |
-
if item['quantity'] <= 0:
|
| 3718 |
order['cart'].remove(item)
|
| 3719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'])
|