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