Update app.py
Browse files
app.py
CHANGED
|
@@ -169,7 +169,7 @@ def generate_receipt_html(transaction):
|
|
| 169 |
if has_discounts:
|
| 170 |
table_headers = """
|
| 171 |
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 172 |
-
<th style="text-align: left;
|
| 173 |
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 174 |
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 175 |
<th style="text-align: right; white-space: nowrap;">Скидка</th>
|
|
@@ -179,7 +179,7 @@ def generate_receipt_html(transaction):
|
|
| 179 |
else:
|
| 180 |
table_headers = """
|
| 181 |
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 182 |
-
<th style="text-align: left;
|
| 183 |
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 184 |
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 185 |
<th style="text-align: right; white-space: nowrap;">Сумма</th>
|
|
@@ -194,7 +194,7 @@ def generate_receipt_html(transaction):
|
|
| 194 |
items_html += f"""
|
| 195 |
<tr>
|
| 196 |
<td style="text-align: center; white-space: nowrap;">{i + 1}</td>
|
| 197 |
-
<td
|
| 198 |
<td style="text-align: right; white-space: nowrap;">{item['quantity']}</td>
|
| 199 |
<td style="text-align: right; white-space: nowrap;">{format_currency_py(item['price_at_sale'])}</td>
|
| 200 |
{discount_cell}
|
|
@@ -266,9 +266,11 @@ def generate_receipt_html(transaction):
|
|
| 266 |
.details-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; font-size: 14px; }}
|
| 267 |
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
|
| 268 |
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; white-space: nowrap; }}
|
|
|
|
| 269 |
table td {{ padding: 8px; border-bottom: 1px solid #eee; white-space: nowrap; }}
|
|
|
|
| 270 |
table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
|
| 271 |
-
.footer-info {{ font-size: 14px; margin-top: 20px; }}
|
| 272 |
.print-hide {{ display: block; }}
|
| 273 |
@media print {{
|
| 274 |
body {{ margin: 0; padding: 0; background-color: #fff; }}
|
|
@@ -307,8 +309,13 @@ def generate_receipt_html(transaction):
|
|
| 307 |
</table>
|
| 308 |
{note_html}
|
| 309 |
<div class="footer-info">
|
| 310 |
-
<
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
</div>
|
| 313 |
{links_html}
|
| 314 |
</div>
|
|
@@ -1591,6 +1598,39 @@ def manage_link():
|
|
| 1591 |
upload_db_to_hf('links')
|
| 1592 |
return redirect(url_for('admin_panel'))
|
| 1593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1594 |
@app.route('/cashier_login', methods=['GET', 'POST'])
|
| 1595 |
def cashier_login():
|
| 1596 |
if request.method == 'POST':
|
|
@@ -2118,6 +2158,12 @@ BASE_TEMPLATE = """
|
|
| 2118 |
<button class="btn btn-dark d-lg-none" id="sidebar-toggle"><i class="fas fa-bars"></i></button>
|
| 2119 |
<h2 class="h3 mb-0">__TITLE__</h2>
|
| 2120 |
<div class="d-flex align-items-center">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2121 |
{% if not session.admin_logged_in %}
|
| 2122 |
<a href="{{ url_for('admin_login') }}" class="btn btn-outline-primary btn-sm me-3"><i class="fas fa-user-shield me-1"></i>Войти как админ</a>
|
| 2123 |
{% endif %}
|
|
@@ -2208,6 +2254,21 @@ BASE_TEMPLATE = """
|
|
| 2208 |
</div>
|
| 2209 |
</div>
|
| 2210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2211 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 2212 |
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
| 2213 |
<script>
|
|
@@ -2505,6 +2566,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2505 |
|
| 2506 |
let audioCtx;
|
| 2507 |
let isScannerPaused = false;
|
|
|
|
| 2508 |
|
| 2509 |
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal'));
|
| 2510 |
const priceSelectModal = new bootstrap.Modal(document.getElementById('priceSelectModal'));
|
|
@@ -2987,14 +3049,59 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2987 |
document.getElementById('scan-btn').addEventListener('click', startScanner);
|
| 2988 |
document.getElementById('stop-scan-btn').addEventListener('click', stopScanner);
|
| 2989 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2990 |
const updateSessionUI = () => {
|
| 2991 |
const sessionInfoEl = document.getElementById('session-info');
|
| 2992 |
const shiftControlsEl = document.getElementById('shift-controls');
|
| 2993 |
|
|
|
|
|
|
|
| 2994 |
if (session.shift) {
|
| 2995 |
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong> | Касса: <strong>${session.kassa.name}</strong>`;
|
| 2996 |
shiftControlsEl.innerHTML = '<button id="end-shift-btn" class="btn btn-danger btn-sm">Закончить смену</button>';
|
| 2997 |
document.getElementById('end-shift-btn').addEventListener('click', handleEndShift);
|
|
|
|
|
|
|
| 2998 |
} else if (session.cashier) {
|
| 2999 |
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong>. Смена не начата.`;
|
| 3000 |
shiftControlsEl.innerHTML = '<button id="start-shift-btn" class="btn btn-success btn-sm">Начать смену</button>';
|
|
@@ -3002,9 +3109,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 3002 |
document.getElementById('start-shift-cashier-name').textContent = session.cashier.name;
|
| 3003 |
startShiftModal.show();
|
| 3004 |
});
|
|
|
|
|
|
|
| 3005 |
} else {
|
| 3006 |
sessionInfoEl.innerHTML = 'Нет активного кассира';
|
| 3007 |
shiftControlsEl.innerHTML = '';
|
|
|
|
| 3008 |
}
|
| 3009 |
};
|
| 3010 |
|
|
@@ -4198,7 +4308,7 @@ ITEM_MOVEMENT_CONTENT = """
|
|
| 4198 |
{% else %}<span class="badge bg-secondary">{{ m.type }}</span>{% endif %}
|
| 4199 |
</td>
|
| 4200 |
<td>{{ m.variant_name }}</td>
|
| 4201 |
-
|
| 4202 |
<td>{{ m.user }}</td>
|
| 4203 |
<td class="text-end">{{ format_currency_py(m.price) }} ₸</td>
|
| 4204 |
<td class="text-end fw-bold {% if m.qty > 0 %}text-success{% elif m.qty < 0 %}text-danger{% endif %}">
|
|
|
|
| 169 |
if has_discounts:
|
| 170 |
table_headers = """
|
| 171 |
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 172 |
+
<th class="name-col" style="text-align: left;">Наименование</th>
|
| 173 |
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 174 |
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 175 |
<th style="text-align: right; white-space: nowrap;">Скидка</th>
|
|
|
|
| 179 |
else:
|
| 180 |
table_headers = """
|
| 181 |
<th style="text-align: center; width: 5%; white-space: nowrap;">№</th>
|
| 182 |
+
<th class="name-col" style="text-align: left;">Наименование</th>
|
| 183 |
<th style="text-align: right; white-space: nowrap;">Кол-во</th>
|
| 184 |
<th style="text-align: right; white-space: nowrap;">Цена</th>
|
| 185 |
<th style="text-align: right; white-space: nowrap;">Сумма</th>
|
|
|
|
| 194 |
items_html += f"""
|
| 195 |
<tr>
|
| 196 |
<td style="text-align: center; white-space: nowrap;">{i + 1}</td>
|
| 197 |
+
<td class="name-col">{item['name']}</td>
|
| 198 |
<td style="text-align: right; white-space: nowrap;">{item['quantity']}</td>
|
| 199 |
<td style="text-align: right; white-space: nowrap;">{format_currency_py(item['price_at_sale'])}</td>
|
| 200 |
{discount_cell}
|
|
|
|
| 266 |
.details-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; font-size: 14px; }}
|
| 267 |
table {{ width: 100%; line-height: inherit; text-align: left; border-collapse: collapse; }}
|
| 268 |
table th {{ background: #f2f2f2; font-weight: bold; padding: 8px; border-bottom: 2px solid #ddd; white-space: nowrap; }}
|
| 269 |
+
table th.name-col {{ white-space: normal; }}
|
| 270 |
table td {{ padding: 8px; border-bottom: 1px solid #eee; white-space: nowrap; }}
|
| 271 |
+
table td.name-col {{ white-space: normal; word-wrap: break-word; max-width: 250px; min-width: 150px; }}
|
| 272 |
table tr.total td {{ font-weight: bold; font-size: 1.1em; border-top: 2px solid #ddd; }}
|
| 273 |
+
.footer-info {{ font-size: 14px; margin-top: 20px; display: flex; justify-content: space-between; align-items: flex-end; }}
|
| 274 |
.print-hide {{ display: block; }}
|
| 275 |
@media print {{
|
| 276 |
body {{ margin: 0; padding: 0; background-color: #fff; }}
|
|
|
|
| 309 |
</table>
|
| 310 |
{note_html}
|
| 311 |
<div class="footer-info">
|
| 312 |
+
<div>
|
| 313 |
+
<p style="margin: 2px 0;">Способ оплаты: {'Наличные' if transaction['payment_method'] == 'cash' else 'Карта'}</p>
|
| 314 |
+
<p style="margin: 2px 0;">Кассир: {transaction['user_name']}</p>
|
| 315 |
+
</div>
|
| 316 |
+
<div>
|
| 317 |
+
<img src="https://huggingface.co/spaces/metastore/tech/resolve/main/1778729143842-019e2483-619b-7600-b6f1-03c567356039.png" style="width: 80px; height: auto;">
|
| 318 |
+
</div>
|
| 319 |
</div>
|
| 320 |
{links_html}
|
| 321 |
</div>
|
|
|
|
| 1598 |
upload_db_to_hf('links')
|
| 1599 |
return redirect(url_for('admin_panel'))
|
| 1600 |
|
| 1601 |
+
@app.route('/api/notifications/stock', methods=['GET'])
|
| 1602 |
+
def get_stock_notifications():
|
| 1603 |
+
user_id = request.args.get('user_id')
|
| 1604 |
+
if not user_id:
|
| 1605 |
+
return jsonify({'success': False, 'message': 'Missing user_id'}), 400
|
| 1606 |
+
users = load_json_data('users')
|
| 1607 |
+
user = find_item_by_field(users, 'id', user_id)
|
| 1608 |
+
if not user:
|
| 1609 |
+
return jsonify({'success': False, 'message': 'User not found'}), 404
|
| 1610 |
+
|
| 1611 |
+
last_view = user.get('last_stock_view', '2000-01-01T00:00:00')
|
| 1612 |
+
stock_history = load_json_data('stock_history')
|
| 1613 |
+
new_stocks = [
|
| 1614 |
+
s for s in stock_history
|
| 1615 |
+
if s.get('type') == 'stock_in' and s.get('timestamp', '') > last_view
|
| 1616 |
+
]
|
| 1617 |
+
return jsonify({'success': True, 'count': len(new_stocks), 'items': new_stocks[-50:]})
|
| 1618 |
+
|
| 1619 |
+
@app.route('/api/notifications/stock/view', methods=['POST'])
|
| 1620 |
+
def mark_stock_viewed():
|
| 1621 |
+
data = request.json
|
| 1622 |
+
user_id = data.get('user_id')
|
| 1623 |
+
if not user_id:
|
| 1624 |
+
return jsonify({'success': False})
|
| 1625 |
+
users = load_json_data('users')
|
| 1626 |
+
for i, u in enumerate(users):
|
| 1627 |
+
if u.get('id') == user_id:
|
| 1628 |
+
users[i]['last_stock_view'] = get_current_time().isoformat()
|
| 1629 |
+
save_json_data('users', users)
|
| 1630 |
+
upload_db_to_hf('users')
|
| 1631 |
+
return jsonify({'success': True})
|
| 1632 |
+
return jsonify({'success': False})
|
| 1633 |
+
|
| 1634 |
@app.route('/cashier_login', methods=['GET', 'POST'])
|
| 1635 |
def cashier_login():
|
| 1636 |
if request.method == 'POST':
|
|
|
|
| 2158 |
<button class="btn btn-dark d-lg-none" id="sidebar-toggle"><i class="fas fa-bars"></i></button>
|
| 2159 |
<h2 class="h3 mb-0">__TITLE__</h2>
|
| 2160 |
<div class="d-flex align-items-center">
|
| 2161 |
+
<div id="stock-notification-container" class="me-3" style="display:none;">
|
| 2162 |
+
<button id="stock-notification-btn" class="btn btn-outline-info btn-sm position-relative" data-bs-toggle="modal" data-bs-target="#stockNotificationModal">
|
| 2163 |
+
<i class="fas fa-bell"></i>
|
| 2164 |
+
<span id="stock-notification-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="display:none; font-size: 0.65em;">0</span>
|
| 2165 |
+
</button>
|
| 2166 |
+
</div>
|
| 2167 |
{% if not session.admin_logged_in %}
|
| 2168 |
<a href="{{ url_for('admin_login') }}" class="btn btn-outline-primary btn-sm me-3"><i class="fas fa-user-shield me-1"></i>Войти как админ</a>
|
| 2169 |
{% endif %}
|
|
|
|
| 2254 |
</div>
|
| 2255 |
</div>
|
| 2256 |
|
| 2257 |
+
<div class="modal fade" id="stockNotificationModal" tabindex="-1">
|
| 2258 |
+
<div class="modal-dialog modal-dialog-scrollable">
|
| 2259 |
+
<div class="modal-content">
|
| 2260 |
+
<div class="modal-header">
|
| 2261 |
+
<h5 class="modal-title">Новые поступления</h5>
|
| 2262 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 2263 |
+
</div>
|
| 2264 |
+
<div class="modal-body">
|
| 2265 |
+
<ul id="stock-notification-list" class="list-group">
|
| 2266 |
+
</ul>
|
| 2267 |
+
</div>
|
| 2268 |
+
</div>
|
| 2269 |
+
</div>
|
| 2270 |
+
</div>
|
| 2271 |
+
|
| 2272 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 2273 |
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
| 2274 |
<script>
|
|
|
|
| 2566 |
|
| 2567 |
let audioCtx;
|
| 2568 |
let isScannerPaused = false;
|
| 2569 |
+
let stockInterval;
|
| 2570 |
|
| 2571 |
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal'));
|
| 2572 |
const priceSelectModal = new bootstrap.Modal(document.getElementById('priceSelectModal'));
|
|
|
|
| 3049 |
document.getElementById('scan-btn').addEventListener('click', startScanner);
|
| 3050 |
document.getElementById('stop-scan-btn').addEventListener('click', stopScanner);
|
| 3051 |
|
| 3052 |
+
const checkStockNotifications = () => {
|
| 3053 |
+
if (!session.cashier) {
|
| 3054 |
+
document.getElementById('stock-notification-container').style.display = 'none';
|
| 3055 |
+
return;
|
| 3056 |
+
}
|
| 3057 |
+
document.getElementById('stock-notification-container').style.display = 'block';
|
| 3058 |
+
fetch(`/api/notifications/stock?user_id=${session.cashier.id}`)
|
| 3059 |
+
.then(r => r.json())
|
| 3060 |
+
.then(data => {
|
| 3061 |
+
if (data.success) {
|
| 3062 |
+
const badge = document.getElementById('stock-notification-badge');
|
| 3063 |
+
const list = document.getElementById('stock-notification-list');
|
| 3064 |
+
if (data.count > 0) {
|
| 3065 |
+
badge.textContent = data.count;
|
| 3066 |
+
badge.style.display = 'block';
|
| 3067 |
+
let itemsHtml = data.items.reverse().map(item => `<li class="list-group-item"><strong>${item.product_name}</strong> (${item.variant_name}) - ${item.quantity} шт.<br><small class="text-muted">${new Date(item.timestamp).toLocaleString('ru-RU')}</small></li>`).join('');
|
| 3068 |
+
list.innerHTML = itemsHtml;
|
| 3069 |
+
} else {
|
| 3070 |
+
badge.style.display = 'none';
|
| 3071 |
+
list.innerHTML = '<li class="list-group-item text-center text-muted">Нет новых поступлений</li>';
|
| 3072 |
+
}
|
| 3073 |
+
}
|
| 3074 |
+
});
|
| 3075 |
+
};
|
| 3076 |
+
|
| 3077 |
+
const stockNotificationModalEl = document.getElementById('stockNotificationModal');
|
| 3078 |
+
if (stockNotificationModalEl) {
|
| 3079 |
+
stockNotificationModalEl.addEventListener('hidden.bs.modal', () => {
|
| 3080 |
+
if (session.cashier && document.getElementById('stock-notification-badge').style.display === 'block') {
|
| 3081 |
+
fetch('/api/notifications/stock/view', {
|
| 3082 |
+
method: 'POST',
|
| 3083 |
+
headers: {'Content-Type': 'application/json'},
|
| 3084 |
+
body: JSON.stringify({user_id: session.cashier.id})
|
| 3085 |
+
}).then(() => {
|
| 3086 |
+
document.getElementById('stock-notification-badge').style.display = 'none';
|
| 3087 |
+
checkStockNotifications();
|
| 3088 |
+
});
|
| 3089 |
+
}
|
| 3090 |
+
});
|
| 3091 |
+
}
|
| 3092 |
+
|
| 3093 |
const updateSessionUI = () => {
|
| 3094 |
const sessionInfoEl = document.getElementById('session-info');
|
| 3095 |
const shiftControlsEl = document.getElementById('shift-controls');
|
| 3096 |
|
| 3097 |
+
clearInterval(stockInterval);
|
| 3098 |
+
|
| 3099 |
if (session.shift) {
|
| 3100 |
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong> | Касса: <strong>${session.kassa.name}</strong>`;
|
| 3101 |
shiftControlsEl.innerHTML = '<button id="end-shift-btn" class="btn btn-danger btn-sm">Закончить смену</button>';
|
| 3102 |
document.getElementById('end-shift-btn').addEventListener('click', handleEndShift);
|
| 3103 |
+
checkStockNotifications();
|
| 3104 |
+
stockInterval = setInterval(checkStockNotifications, 30000);
|
| 3105 |
} else if (session.cashier) {
|
| 3106 |
sessionInfoEl.innerHTML = `Кассир: <strong>${session.cashier.name}</strong>. Смена не начата.`;
|
| 3107 |
shiftControlsEl.innerHTML = '<button id="start-shift-btn" class="btn btn-success btn-sm">Начать смену</button>';
|
|
|
|
| 3109 |
document.getElementById('start-shift-cashier-name').textContent = session.cashier.name;
|
| 3110 |
startShiftModal.show();
|
| 3111 |
});
|
| 3112 |
+
checkStockNotifications();
|
| 3113 |
+
stockInterval = setInterval(checkStockNotifications, 30000);
|
| 3114 |
} else {
|
| 3115 |
sessionInfoEl.innerHTML = 'Нет активного кассира';
|
| 3116 |
shiftControlsEl.innerHTML = '';
|
| 3117 |
+
document.getElementById('stock-notification-container').style.display = 'none';
|
| 3118 |
}
|
| 3119 |
};
|
| 3120 |
|
|
|
|
| 4308 |
{% else %}<span class="badge bg-secondary">{{ m.type }}</span>{% endif %}
|
| 4309 |
</td>
|
| 4310 |
<td>{{ m.variant_name }}</td>
|
| 4311 |
+
<td>{{ m.doc_id }}</td>
|
| 4312 |
<td>{{ m.user }}</td>
|
| 4313 |
<td class="text-end">{{ format_currency_py(m.price) }} ₸</td>
|
| 4314 |
<td class="text-end fw-bold {% if m.qty > 0 %}text-success{% elif m.qty < 0 %}text-danger{% endif %}">
|