Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
# Импортируем необходимые библиотеки
|
| 2 |
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
|
| 3 |
import json
|
|
@@ -359,13 +360,18 @@ def procurement():
|
|
| 359 |
logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True)
|
| 360 |
flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger")
|
| 361 |
|
| 362 |
-
# GET запрос -
|
| 363 |
page_title = "Закуп материалов"
|
| 364 |
page_content = PROCUREMENT_CONTENT # Используем переменную с контентом
|
| 365 |
page_scripts = PROCUREMENT_SCRIPTS # Используем переменную со скриптами
|
| 366 |
-
|
| 367 |
-
#
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
# 2. Маршрут "Раскрой"
|
|
@@ -495,25 +501,30 @@ def cutting():
|
|
| 495 |
logging.error(f"Ошибка при обработке раскроя: {e}", exc_info=True)
|
| 496 |
flash(f"Произошла внутренняя ошибка при обработке раскроя: {e}", "danger")
|
| 497 |
|
| 498 |
-
# GET запрос
|
| 499 |
# Передаем материалы, преобразовав количество в Decimal для отображения
|
| 500 |
fabrics_dec = []
|
| 501 |
for f in fabrics:
|
| 502 |
f_copy = f.copy()
|
| 503 |
-
f_copy['
|
| 504 |
fabrics_dec.append(f_copy)
|
| 505 |
|
| 506 |
fittings_dec = []
|
| 507 |
for f in fittings:
|
| 508 |
f_copy = f.copy()
|
| 509 |
-
f_copy['
|
| 510 |
fittings_dec.append(f_copy)
|
| 511 |
|
| 512 |
page_title = "Раскрой ткани"
|
| 513 |
page_content = CUTTING_CONTENT
|
| 514 |
page_scripts = CUTTING_SCRIPTS
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
|
| 518 |
|
| 519 |
# 3. Маршрут "Пошив"
|
|
@@ -701,7 +712,7 @@ def sewing():
|
|
| 701 |
'fabric_id': cutting_task['fabric_id'], # Сохраняем для справки
|
| 702 |
'fabric_name': cutting_task['fabric_name'],
|
| 703 |
'fittings_consumed': fittings_consumed_list, # Фактически использовано на пошив
|
| 704 |
-
'defects_reported':
|
| 705 |
'status': 'pending_qc', # 'pending_qc', 'completed' (packed)
|
| 706 |
'timestamp_created': current_time,
|
| 707 |
'timestamp_completed': None, # Заполнится при ОТК
|
|
@@ -709,16 +720,17 @@ def sewing():
|
|
| 709 |
'qc_defective_quantity': 0
|
| 710 |
}
|
| 711 |
|
| 712 |
-
# Присваиваем ID задания на пошив
|
| 713 |
for defect in defect_log_list:
|
| 714 |
defect['sewing_task_id'] = new_sewing_task['id']
|
| 715 |
# Преобразуем Decimal обратно в строку для JSON, если это ткань
|
| 716 |
-
if defect['
|
| 717 |
defect['quantity'] = str(defect['quantity'])
|
|
|
|
|
|
|
| 718 |
|
| 719 |
|
| 720 |
-
# 5. Обновление статуса задания на раскро
|
| 721 |
-
# Пока считаем, что отправка на пошив завершает задание на раскрой целиком.
|
| 722 |
task_updated = False
|
| 723 |
for i, task in enumerate(data.get('cutting_tasks', [])):
|
| 724 |
if task.get('id') == cutting_task_id:
|
|
@@ -734,9 +746,10 @@ def sewing():
|
|
| 734 |
if 'sewing_tasks' not in data: data['sewing_tasks'] = []
|
| 735 |
data['sewing_tasks'].append(new_sewing_task)
|
| 736 |
|
| 737 |
-
if defect_log_list:
|
| 738 |
if 'defect_log' not in data: data['defect_log'] = []
|
| 739 |
-
|
|
|
|
| 740 |
|
| 741 |
# Обновляем основной список материалов
|
| 742 |
data['materials'] = materials_list
|
|
@@ -752,20 +765,23 @@ def sewing():
|
|
| 752 |
logging.error(f"Ошибка при обработке пошива: {e}", exc_info=True)
|
| 753 |
flash(f"Произошла внутренняя ошибка при обработке пошива: {e}", "danger")
|
| 754 |
|
| 755 |
-
# GET запрос
|
| 756 |
# Преобразуем данные для шаблона
|
| 757 |
tasks_for_template = []
|
| 758 |
all_materials_dict = {m['id']: m for m in data.get('materials', [])} # Словарь для быстрого поиска остатков
|
| 759 |
|
| 760 |
for task in pending_cutting_tasks:
|
| 761 |
task_copy = task.copy()
|
| 762 |
-
|
|
|
|
|
|
|
| 763 |
# Получаем текущие остатки фурнитуры для отображения в деталях
|
| 764 |
if 'required_fittings' in task_copy:
|
| 765 |
for fitting in task_copy['required_fittings']:
|
| 766 |
mat_data = all_materials_dict.get(fitting['fitting_id'])
|
| 767 |
fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0')
|
| 768 |
fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт'
|
|
|
|
| 769 |
tasks_for_template.append(task_copy)
|
| 770 |
|
| 771 |
# Получаем все материалы для выбора брака (с преобразованными количествами)
|
|
@@ -779,8 +795,12 @@ def sewing():
|
|
| 779 |
page_title = "Пошив изделий"
|
| 780 |
page_content = SEWING_CONTENT
|
| 781 |
page_scripts = SEWING_SCRIPTS
|
| 782 |
-
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
cutting_tasks=tasks_for_template,
|
| 785 |
all_materials=all_materials_dec) # Передаем материалы с quantity_decimal
|
| 786 |
|
|
@@ -852,7 +872,6 @@ def qc_packing():
|
|
| 852 |
data['qc_packing_items'].append(packed_item)
|
| 853 |
|
| 854 |
# Регистрация брака на этапе ОТК (если есть)
|
| 855 |
-
# Здесь мы не списываем материал, а просто логируем брак готового изделия
|
| 856 |
if quantity_defective > 0:
|
| 857 |
defect_log_entry = {
|
| 858 |
'log_id': uuid.uuid4().hex,
|
|
@@ -875,9 +894,13 @@ def qc_packing():
|
|
| 875 |
task_updated = False
|
| 876 |
for i, task in enumerate(data.get('sewing_tasks', [])):
|
| 877 |
if task.get('id') == sewing_task_id:
|
| 878 |
-
#
|
| 879 |
-
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
task['status'] = 'completed' # Считаем завершенным после обработки
|
| 882 |
task['timestamp_completed'] = current_time # Время последнего ОТК
|
| 883 |
task_updated = True
|
|
@@ -895,13 +918,17 @@ def qc_packing():
|
|
| 895 |
logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True)
|
| 896 |
flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger")
|
| 897 |
|
| 898 |
-
# GET запрос
|
| 899 |
page_title = "ОТК и Упаковка"
|
| 900 |
page_content = QC_PACKING_CONTENT
|
| 901 |
page_scripts = QC_PACKING_SCRIPTS
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
# Передаем задачи, ожидающие ОТК, в шаблон
|
| 904 |
-
return render_template_string(
|
| 905 |
|
| 906 |
|
| 907 |
# 5. Маршрут "Админ-панель"
|
|
@@ -942,6 +969,11 @@ def admin_panel():
|
|
| 942 |
# Используем find_sewing_task_by_id, чтобы получить уже преобразованные числа
|
| 943 |
task_data = find_sewing_task_by_id(t['id'])
|
| 944 |
if task_data: # Убедимся, что задача найдена
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
sewing_tasks_view.append(task_data)
|
| 946 |
else:
|
| 947 |
logging.warning(f"Задача на пошив с ID {t['id']} не найдена при подготовке для админки.")
|
|
@@ -959,7 +991,7 @@ def admin_panel():
|
|
| 959 |
packed_items_view.append(item_copy)
|
| 960 |
|
| 961 |
defect_log_view = []
|
| 962 |
-
total_defect_items = 0 # Будем считать штуки, метры отдельно
|
| 963 |
total_defect_fabric_m = Decimal('0.00')
|
| 964 |
total_defect_fittings_pcs = 0
|
| 965 |
total_defect_finished_pcs = 0
|
|
@@ -991,11 +1023,14 @@ def admin_panel():
|
|
| 991 |
page_title = "Админ-панель"
|
| 992 |
page_content = ADMIN_CONTENT
|
| 993 |
page_scripts = ADMIN_SCRIPTS # Если есть специфичные скрипты для админки
|
| 994 |
-
|
|
|
|
|
|
|
|
|
|
| 995 |
|
| 996 |
# Передаем подготовленные данные
|
| 997 |
return render_template_string(
|
| 998 |
-
|
| 999 |
materials=materials_view,
|
| 1000 |
cutting_tasks=cutting_tasks_view,
|
| 1001 |
sewing_tasks=sewing_tasks_view,
|
|
@@ -1127,51 +1162,51 @@ def download_hf():
|
|
| 1127 |
|
| 1128 |
# --- HTML Шаблоны (как строки Python) ---
|
| 1129 |
|
| 1130 |
-
# Базовый шаблон с плейсхолдер
|
| 1131 |
BASE_TEMPLATE = """
|
| 1132 |
<!DOCTYPE html>
|
| 1133 |
<html lang="ru">
|
| 1134 |
<head>
|
| 1135 |
<meta charset="UTF-8">
|
| 1136 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1137 |
-
<title>
|
| 1138 |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
| 1139 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1140 |
<style>
|
| 1141 |
-
body {
|
| 1142 |
background-color: #f8f9fa; /* Светлый фон */
|
| 1143 |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 1144 |
padding-top: 70px; /* Отступ для фиксированной навигации */
|
| 1145 |
padding-bottom: 60px; /* Отступ для футера */
|
| 1146 |
position: relative;
|
| 1147 |
min-height: 100vh;
|
| 1148 |
-
}
|
| 1149 |
-
.navbar {
|
| 1150 |
background-color: #343a40; /* Темная навигация */
|
| 1151 |
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
| 1152 |
-
}
|
| 1153 |
-
.navbar-brand, .nav-link {
|
| 1154 |
color: #f8f9fa !important; /* Светлый текст */
|
| 1155 |
-
}
|
| 1156 |
-
.navbar-brand:hover, .nav-link:hover {
|
| 1157 |
color: #adb5bd !important; /* Чуть темнее при наведении */
|
| 1158 |
-
}
|
| 1159 |
-
.nav-link.active {
|
| 1160 |
font-weight: bold;
|
| 1161 |
color: #ffffff !important;
|
| 1162 |
border-bottom: 2px solid #0d6efd; /* Подчеркивание активного пункта */
|
| 1163 |
-
}
|
| 1164 |
-
.card {
|
| 1165 |
margin-bottom: 1.5rem;
|
| 1166 |
border: none; /* Убираем стандартную рамку */
|
| 1167 |
border-radius: 0.5rem; /* Скругление углов */
|
| 1168 |
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); /* Тень */
|
| 1169 |
transition: transform 0.2s ease-in-out;
|
| 1170 |
-
}
|
| 1171 |
-
.card:hover {
|
| 1172 |
/* transform: translateY(-3px); Легкий подъем при наведении - убрал, может мешать */
|
| 1173 |
-
}
|
| 1174 |
-
.card-header {
|
| 1175 |
background-color: #6c757d; /* Серая шапка */
|
| 1176 |
color: white;
|
| 1177 |
font-weight: 600;
|
|
@@ -1180,86 +1215,98 @@ BASE_TEMPLATE = """
|
|
| 1180 |
border-top-right-radius: 0.5rem;
|
| 1181 |
display: flex;
|
| 1182 |
align-items: center;
|
| 1183 |
-
}
|
| 1184 |
-
.card-header i {
|
| 1185 |
margin-right: 0.5rem; /* Отступ для иконки в шапке */
|
| 1186 |
-
}
|
| 1187 |
-
.btn-primary {
|
| 1188 |
background-color: #0d6efd;
|
| 1189 |
border-color: #0d6efd;
|
| 1190 |
-
}
|
| 1191 |
-
.btn-primary:hover {
|
| 1192 |
background-color: #0b5ed7;
|
| 1193 |
border-color: #0a58ca;
|
| 1194 |
-
}
|
| 1195 |
-
.btn-success {
|
| 1196 |
background-color: #198754;
|
| 1197 |
border-color: #198754;
|
| 1198 |
-
}
|
| 1199 |
-
.btn-danger {
|
| 1200 |
background-color: #dc3545;
|
| 1201 |
border-color: #dc3545;
|
| 1202 |
-
}
|
| 1203 |
-
.btn-warning {
|
| 1204 |
background-color: #ffc107;
|
| 1205 |
border-color: #ffc107;
|
| 1206 |
color: #212529; /* Темный текст для желтой кнопки */
|
| 1207 |
-
}
|
| 1208 |
-
.btn-info {
|
| 1209 |
background-color: #0dcaf0;
|
| 1210 |
border-color: #0dcaf0;
|
| 1211 |
color: #000;
|
| 1212 |
-
}
|
| 1213 |
-
.table-hover tbody tr:hover {
|
| 1214 |
background-color: rgba(0, 0, 0, 0.05); /* Легкое выделение строки таблицы */
|
| 1215 |
cursor: pointer; /* Намек на интерактивность (если будет) */
|
| 1216 |
-
}
|
| 1217 |
-
.container-main {
|
| 1218 |
max-width: 1400px; /* Чуть шире контейнер */
|
| 1219 |
margin: 0 auto; /* Центрирование */
|
| 1220 |
padding-left: 15px;
|
| 1221 |
padding-right: 15px;
|
| 1222 |
-
}
|
| 1223 |
/* Статусы */
|
| 1224 |
-
.status-pending {
|
| 1225 |
-
.status-completed {
|
| 1226 |
-
.status-pending_qc {
|
| 1227 |
|
| 1228 |
-
.flash-messages .alert {
|
| 1229 |
margin-bottom: 1rem;
|
| 1230 |
border-radius: 0.5rem;
|
| 1231 |
-
}
|
| 1232 |
/* Стиль для динамически добавляемых строк */
|
| 1233 |
-
.dynamic-row, .dynamic-fitting-row, .dynamic-defect-row {
|
| 1234 |
border: 1px solid #eee;
|
| 1235 |
padding: 15px;
|
| 1236 |
margin-bottom: 15px;
|
| 1237 |
border-radius: 5px;
|
| 1238 |
background-color: #fdfdfd;
|
| 1239 |
position: relative; /* Для позиционирования кнопки удаления */
|
| 1240 |
-
}
|
| 1241 |
-
.remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn {
|
| 1242 |
position: absolute;
|
| 1243 |
top: 10px;
|
| 1244 |
right: 10px;
|
| 1245 |
-
|
| 1246 |
-
}
|
| 1247 |
/* Responsive table */
|
| 1248 |
-
.table-responsive {
|
| 1249 |
margin-bottom: 1rem;
|
| 1250 |
-
}
|
| 1251 |
-
.table th, .table td {
|
| 1252 |
vertical-align: middle; /* Выравнивание по центру */
|
| 1253 |
-
|
| 1254 |
-
|
|
|
|
| 1255 |
color: #6c757d; /* Серый цвет для мелкого текста */
|
| 1256 |
display: block; /* ID на новой строке */
|
| 1257 |
-
|
| 1258 |
-
|
|
|
|
| 1259 |
font-size: 0.8em;
|
| 1260 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1261 |
/* Footer */
|
| 1262 |
-
.footer {
|
| 1263 |
position: absolute;
|
| 1264 |
bottom: 0;
|
| 1265 |
width: 100%;
|
|
@@ -1268,19 +1315,19 @@ BASE_TEMPLATE = """
|
|
| 1268 |
text-align: center;
|
| 1269 |
padding: 10px 0;
|
| 1270 |
font-size: 0.9em;
|
| 1271 |
-
}
|
| 1272 |
/* Скрытие стрелок у number инпутов */
|
| 1273 |
input[type=number]::-webkit-inner-spin-button,
|
| 1274 |
-
input[type=number]::-webkit-outer-spin-button {
|
| 1275 |
-webkit-appearance: none;
|
| 1276 |
margin: 0;
|
| 1277 |
-
}
|
| 1278 |
-
input[type=number] {
|
| 1279 |
-moz-appearance: textfield; /* Firefox */
|
| 1280 |
-
}
|
| 1281 |
-
.form-text {
|
| 1282 |
font-size: 0.8rem;
|
| 1283 |
-
}
|
| 1284 |
</style>
|
| 1285 |
</head>
|
| 1286 |
<body>
|
|
@@ -1310,13 +1357,13 @@ BASE_TEMPLATE = """
|
|
| 1310 |
</li>
|
| 1311 |
</ul>
|
| 1312 |
<!-- Кнопки Hugging Face справа -->
|
| 1313 |
-
<div class="d-flex">
|
| 1314 |
-
<form method="POST" action="{{ url_for('backup_hf') }}" class="me-2">
|
| 1315 |
<button type="submit" class="btn btn-sm btn-outline-light" title="Создать резервную копию на Hugging Face">
|
| 1316 |
<i class="fas fa-cloud-upload-alt"></i> Backup
|
| 1317 |
</button>
|
| 1318 |
</form>
|
| 1319 |
-
<form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Это перезапишет локальные данные последней версией с Hugging Face. Вы уверены?');">
|
| 1320 |
<button type="submit" class="btn btn-sm btn-outline-warning" title="Скачать базу данных с Hugging Face (перезапишет локальную)">
|
| 1321 |
<i class="fas fa-cloud-download-alt"></i> Download DB
|
| 1322 |
</button>
|
|
@@ -1332,7 +1379,13 @@ BASE_TEMPLATE = """
|
|
| 1332 |
{% if messages %}
|
| 1333 |
<div class="flash-messages">
|
| 1334 |
{% for category, message in messages %}
|
| 1335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1336 |
{{ message }}
|
| 1337 |
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 1338 |
</div>
|
|
@@ -1342,7 +1395,7 @@ BASE_TEMPLATE = """
|
|
| 1342 |
{% endwith %}
|
| 1343 |
|
| 1344 |
{# Основной контент страницы #}
|
| 1345 |
-
|
| 1346 |
|
| 1347 |
</div>
|
| 1348 |
|
|
@@ -1354,12 +1407,12 @@ BASE_TEMPLATE = """
|
|
| 1354 |
|
| 1355 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
| 1356 |
{# Скрипты для конкретной страницы #}
|
| 1357 |
-
|
| 1358 |
</body>
|
| 1359 |
</html>
|
| 1360 |
"""
|
| 1361 |
|
| 1362 |
-
# --- Контент и скрипты для страниц ---
|
| 1363 |
|
| 1364 |
PROCUREMENT_CONTENT = """
|
| 1365 |
<div class="card">
|
|
@@ -1430,7 +1483,10 @@ PROCUREMENT_SCRIPTS = """
|
|
| 1430 |
function addRow() {
|
| 1431 |
const container = document.getElementById('material-rows');
|
| 1432 |
const firstRow = container.querySelector('.dynamic-row'); // Берем первую строку как шаблон
|
| 1433 |
-
if (!firstRow)
|
|
|
|
|
|
|
|
|
|
| 1434 |
const newRow = firstRow.cloneNode(true);
|
| 1435 |
|
| 1436 |
// Очищаем значения в новой строке
|
|
@@ -1465,12 +1521,11 @@ PROCUREMENT_SCRIPTS = """
|
|
| 1465 |
|
| 1466 |
container.appendChild(newRow);
|
| 1467 |
attachCategoryChangeEvent(newRow); // Добавляем обработчик для новой строки
|
| 1468 |
-
// updateRemoveButtons не нужна, если кнопка всегда видима и позиционируется абсолютно
|
| 1469 |
}
|
| 1470 |
|
| 1471 |
function removeRow(button) {
|
| 1472 |
const row = button.closest('.dynamic-row');
|
| 1473 |
-
// Позволяем удалять любую строку
|
| 1474 |
if (row) {
|
| 1475 |
row.remove();
|
| 1476 |
}
|
|
@@ -1509,14 +1564,16 @@ PROCUREMENT_SCRIPTS = """
|
|
| 1509 |
|
| 1510 |
// Инициализация при загрузке страницы
|
| 1511 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
|
|
|
|
|
|
| 1520 |
});
|
| 1521 |
</script>
|
| 1522 |
"""
|
|
@@ -1596,11 +1653,11 @@ CUTTING_SCRIPTS = """
|
|
| 1596 |
if (selectedOption && selectedOption.value) {
|
| 1597 |
const quantity = selectedOption.getAttribute('data-quantity'); // Уже содержит запятую, если надо
|
| 1598 |
const unit = selectedOption.getAttribute('data-unit');
|
| 1599 |
-
availableDiv.textContent = `В наличии: ${quantity} ${unit}`;
|
| 1600 |
-
unitSpan.textContent = unit; // Обновляем единицу измерения в поле расхода
|
| 1601 |
} else {
|
| 1602 |
-
|
| 1603 |
-
|
| 1604 |
}
|
| 1605 |
}
|
| 1606 |
|
|
@@ -1673,15 +1730,18 @@ CUTTING_SCRIPTS = """
|
|
| 1673 |
// Инициализация при загрузке
|
| 1674 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1675 |
updateAvailableQuantity(); // Вызываем при загрузк��
|
| 1676 |
-
document.querySelectorAll('.dynamic-fitting-row')
|
|
|
|
| 1677 |
attachFittingChangeEvent(row);
|
| 1678 |
const fittingSelect = row.querySelector('.fitting-select');
|
| 1679 |
if (fittingSelect) handleFittingChange(fittingSelect); // Инициализируем показ доступности
|
| 1680 |
});
|
| 1681 |
-
// Если нет строк фурнитуры
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
|
|
|
|
|
|
| 1685 |
}
|
| 1686 |
});
|
| 1687 |
</script>
|
|
@@ -1794,14 +1854,14 @@ SEWING_SCRIPTS = """
|
|
| 1794 |
|
| 1795 |
|
| 1796 |
if (selectedOption && selectedOption.value) {
|
| 1797 |
-
const fabricName = selectedOption.getAttribute('data-fabric-name');
|
| 1798 |
-
const cutQuantity = selectedOption.getAttribute('data-cut-quantity');
|
| 1799 |
|
| 1800 |
-
document.getElementById('detail-fabric-name').textContent = fabricName;
|
| 1801 |
-
document.getElementById('detail-cut-quantity').textContent = cutQuantity;
|
| 1802 |
-
document.getElementById('detail-fabric-used').textContent = selectedOption.getAttribute('data-fabric-used');
|
| 1803 |
|
| 1804 |
-
maxSewnSpan.textContent = cutQuantity;
|
| 1805 |
if(sewnInput) sewnInput.max = cutQuantity; // Устанавливаем максимум для поля ввода
|
| 1806 |
|
| 1807 |
// Предзаполняем название изделия на основе ткани, если поле пустое
|
|
@@ -1811,34 +1871,44 @@ SEWING_SCRIPTS = """
|
|
| 1811 |
|
| 1812 |
|
| 1813 |
const fittingsList = document.getElementById('detail-fittings-list');
|
| 1814 |
-
if(fittingsList)
|
| 1815 |
-
|
| 1816 |
-
|
| 1817 |
-
|
| 1818 |
-
|
| 1819 |
-
|
| 1820 |
-
|
| 1821 |
-
|
| 1822 |
-
|
| 1823 |
-
|
| 1824 |
-
|
| 1825 |
-
|
| 1826 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1827 |
} else {
|
| 1828 |
-
|
| 1829 |
-
|
|
|
|
|
|
|
| 1830 |
}
|
| 1831 |
-
|
| 1832 |
-
|
| 1833 |
-
|
| 1834 |
-
|
| 1835 |
-
|
| 1836 |
-
|
| 1837 |
-
|
| 1838 |
-
|
| 1839 |
-
listItem.classList.add('text-muted');
|
| 1840 |
-
fittingsList.appendChild(listItem);
|
| 1841 |
-
}
|
| 1842 |
|
| 1843 |
if(detailsDiv) detailsDiv.style.display = 'block';
|
| 1844 |
} else {
|
|
@@ -1864,6 +1934,14 @@ SEWING_SCRIPTS = """
|
|
| 1864 |
newRow.querySelectorAll('input[type="text"]').forEach(input => input.value = '');
|
| 1865 |
const availabilityDiv = newRow.querySelector('.defect-availability');
|
| 1866 |
if(availabilityDiv) availabilityDiv.textContent = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1867 |
|
| 1868 |
// Кнопка удаления
|
| 1869 |
const removeBtn = newRow.querySelector('.remove-defect-row-btn');
|
|
@@ -1930,16 +2008,17 @@ SEWING_SCRIPTS = """
|
|
| 1930 |
// Инициализация
|
| 1931 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1932 |
showTaskDetails(); // Показать детали для выбранного по умолчанию (если есть)
|
| 1933 |
-
document.querySelectorAll('.dynamic-defect-row')
|
|
|
|
| 1934 |
attachDefectChangeEvent(row);
|
| 1935 |
const defectSelect = row.querySelector('.defect-material-select');
|
| 1936 |
if (defectSelect) handleDefectChange(defectSelect); // Инициализируем доступность и тип инпута
|
| 1937 |
});
|
| 1938 |
-
|
| 1939 |
-
|
| 1940 |
-
|
| 1941 |
-
|
| 1942 |
-
|
| 1943 |
});
|
| 1944 |
</script>
|
| 1945 |
"""
|
|
@@ -1997,7 +2076,7 @@ QC_PACKING_SCRIPTS = """
|
|
| 1997 |
|
| 1998 |
function updateQcQuantities() {
|
| 1999 |
const select = document.getElementById('sewing_task_id');
|
| 2000 |
-
const selectedOption = select.options[select.selectedIndex];
|
| 2001 |
const packedInput = document.getElementById('quantity_packed');
|
| 2002 |
const defectiveInput = document.getElementById('quantity_defective');
|
| 2003 |
const totalInfo = document.getElementById('qc-total-info');
|
|
@@ -2015,7 +2094,7 @@ QC_PACKING_SCRIPTS = """
|
|
| 2015 |
}
|
| 2016 |
if(totalInfo) {
|
| 2017 |
totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака не должна превышать это число.`;
|
| 2018 |
-
totalInfo.classList.remove('text-danger');
|
| 2019 |
}
|
| 2020 |
// Вызываем валидацию сразу
|
| 2021 |
validateQcSum();
|
|
@@ -2030,7 +2109,7 @@ QC_PACKING_SCRIPTS = """
|
|
| 2030 |
defectiveInput.max = '';
|
| 2031 |
defectiveInput.value = '0';
|
| 2032 |
}
|
| 2033 |
-
if(totalInfo) totalInfo.textContent = '';
|
| 2034 |
}
|
| 2035 |
}
|
| 2036 |
|
|
@@ -2045,43 +2124,61 @@ QC_PACKING_SCRIPTS = """
|
|
| 2045 |
const defective = parseInt(defectiveInput.value) || 0;
|
| 2046 |
const totalProcessed = packed + defective;
|
| 2047 |
|
|
|
|
|
|
|
| 2048 |
if (maxAllowedQuantity === 0 && totalProcessed > 0) {
|
| 2049 |
// Если задание не выбрано, но что-то введено
|
| 2050 |
totalInfo.textContent = 'Сначала выберите задание на пошив!';
|
| 2051 |
totalInfo.classList.add('text-danger');
|
| 2052 |
return false;
|
| 2053 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2054 |
|
| 2055 |
if (totalProcessed > maxAllowedQuantity) {
|
| 2056 |
totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает количество сшитых (${maxAllowedQuantity})!`;
|
| 2057 |
totalInfo.classList.add('text-danger');
|
| 2058 |
return false; // Сумма неверна
|
| 2059 |
-
} else {
|
| 2060 |
-
totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед.
|
| 2061 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2062 |
return true; // Сумма верна
|
| 2063 |
}
|
| 2064 |
}
|
| 2065 |
|
| 2066 |
function validateQcForm() {
|
| 2067 |
// Дополнительная проверка перед отправкой формы
|
| 2068 |
-
const isValidSum = validateQcSum();
|
| 2069 |
if (!isValidSum) {
|
| 2070 |
-
alert('Ошибка в количестве упакованных или бракованных изделий. Проверьте введенные значения.');
|
| 2071 |
return false; // Предотвратить отправку формы
|
| 2072 |
}
|
| 2073 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2074 |
const packedInput = document.getElementById('quantity_packed');
|
| 2075 |
const defectiveInput = document.getElementById('quantity_defective');
|
| 2076 |
const packed = parseInt(packedInput.value) || 0;
|
| 2077 |
const defective = parseInt(defectiveInput.value) || 0;
|
| 2078 |
-
|
| 2079 |
if (packed === 0 && defective === 0) {
|
| 2080 |
alert('Укажите количество упакованных или бракованных изделий (хотя бы одно должно быть больше н��ля).');
|
| 2081 |
return false;
|
| 2082 |
}
|
| 2083 |
|
| 2084 |
-
|
| 2085 |
return true; // Разрешить отправку формы
|
| 2086 |
}
|
| 2087 |
|
|
@@ -2260,8 +2357,8 @@ ADMIN_CONTENT = """
|
|
| 2260 |
<tr class="material-row" data-name="{{ material.name|lower }}" data-category="{{ material.category|default('Без категории')|lower }}">
|
| 2261 |
<td title="{{ material.id }}"><small>{{ material.id[:8] }}...</small></td>
|
| 2262 |
<td>{{ material.name }}</td>
|
| 2263 |
-
<td><span class="badge bg-info">{{ material.category | default('Без категории') }}</span></td>
|
| 2264 |
-
<td>{{ 'Ткань' if material.type == 'fabric' else 'Фурнитура' }}</td>
|
| 2265 |
{# Используем quantity_dec для отображения #}
|
| 2266 |
<td data-sort="{{ material.quantity_dec }}">{{ material.quantity_dec|string|replace('.', ',') }}</td>
|
| 2267 |
<td>{{ material.unit }}</td>
|
|
@@ -2471,7 +2568,7 @@ ADMIN_CONTENT = """
|
|
| 2471 |
<td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
|
| 2472 |
<td>{{ defect.quantity_view }}</td> {# Используем quantity_view #}
|
| 2473 |
<td>{{ defect.unit }}</td>
|
| 2474 |
-
<td>{{ defect.stage|replace('_', ' ')|title }}</td>
|
| 2475 |
<td>{{ defect.reason | default('Не указана') }}</td>
|
| 2476 |
<td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
|
| 2477 |
<td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
|
|
@@ -2495,8 +2592,8 @@ const searchInput = document.getElementById('material-search');
|
|
| 2495 |
const tableBody = document.getElementById('materials-table')?.querySelector('tbody'); // Добавил ? для безопасности
|
| 2496 |
|
| 2497 |
if (searchInput && tableBody) {
|
| 2498 |
-
searchInput.addEventListener('
|
| 2499 |
-
const searchTerm = searchInput.value.toLowerCase();
|
| 2500 |
const rows = tableBody.querySelectorAll('tr.material-row'); // Используем класс для точности
|
| 2501 |
|
| 2502 |
rows.forEach(row => {
|
|
@@ -2518,23 +2615,39 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
|
|
| 2518 |
if (!table) return;
|
| 2519 |
const tbody = table.querySelector('tbody');
|
| 2520 |
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2521 |
const rows = Array.from(tbody.querySelectorAll('tr'));
|
| 2522 |
-
|
|
|
|
|
|
|
|
|
|
| 2523 |
|
| 2524 |
// Определяем направление сортировки
|
| 2525 |
-
let currentDir = headerCell
|
| 2526 |
let newDir = currentDir === 'asc' ? 'desc' : 'asc';
|
| 2527 |
|
| 2528 |
-
// Сбрасываем направления у других колонок
|
| 2529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2530 |
// Устанавливаем новое направление для текущей колонки
|
| 2531 |
-
|
| 2532 |
|
| 2533 |
|
| 2534 |
rows.sort((a, b) => {
|
| 2535 |
let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`);
|
| 2536 |
let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`);
|
| 2537 |
|
|
|
|
| 2538 |
let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : '';
|
| 2539 |
let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : '';
|
| 2540 |
|
|
@@ -2543,20 +2656,17 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
|
|
| 2543 |
// Преобразуем в число, заменяя запятую на точку и обрабатывая ошибки
|
| 2544 |
valA = parseFloat(String(valA).replace(',', '.')) || 0;
|
| 2545 |
valB = parseFloat(String(valB).replace(',', '.')) || 0;
|
|
|
|
|
|
|
|
|
|
| 2546 |
|
| 2547 |
} else {
|
| 2548 |
// Текстовое сравнение без учета регистра
|
| 2549 |
valA = valA.toLowerCase();
|
| 2550 |
valB = valB.toLowerCase();
|
|
|
|
|
|
|
| 2551 |
}
|
| 2552 |
-
|
| 2553 |
-
if (valA < valB) {
|
| 2554 |
-
return newDir === 'asc' ? -1 : 1;
|
| 2555 |
-
}
|
| 2556 |
-
if (valA > valB) {
|
| 2557 |
-
return newDir === 'asc' ? 1 : -1;
|
| 2558 |
-
}
|
| 2559 |
-
return 0; // Равны
|
| 2560 |
});
|
| 2561 |
|
| 2562 |
// Удаляем старые строки и вставляем отсортированные
|
|
@@ -2565,38 +2675,44 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
|
|
| 2565 |
}
|
| 2566 |
rows.forEach(row => tbody.appendChild(row));
|
| 2567 |
|
| 2568 |
-
// Обновляем иконк
|
| 2569 |
-
|
| 2570 |
-
|
| 2571 |
-
|
| 2572 |
-
|
| 2573 |
-
|
| 2574 |
-
const icon = headerCell.querySelector('i.fa-sort');
|
| 2575 |
-
if (icon) {
|
| 2576 |
-
icon.classList.remove('fa-sort');
|
| 2577 |
-
icon.classList.add(newDir === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
|
| 2578 |
-
}
|
| 2579 |
-
}
|
| 2580 |
}
|
| 2581 |
|
| 2582 |
-
// --- Активация табов Bootstrap
|
| 2583 |
document.addEventListener('DOMContentLoaded', function () {
|
| 2584 |
-
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button'))
|
| 2585 |
triggerTabList.forEach(function (triggerEl) {
|
| 2586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2587 |
|
| 2588 |
triggerEl.addEventListener('click', function (event) {
|
| 2589 |
-
event.preventDefault()
|
| 2590 |
-
|
| 2591 |
-
|
| 2592 |
-
|
|
|
|
|
|
|
|
|
|
| 2593 |
|
| 2594 |
// Активируем первую вкладку при загрузке, если ни одна не активна
|
| 2595 |
-
|
| 2596 |
-
|
| 2597 |
-
|
| 2598 |
-
|
| 2599 |
-
|
|
|
|
|
|
|
|
|
|
| 2600 |
});
|
| 2601 |
|
| 2602 |
</script>
|
|
@@ -2624,4 +2740,4 @@ if __name__ == '__main__':
|
|
| 2624 |
logging.info("Запуск Flask приложения на порту 7860...")
|
| 2625 |
# debug=False для продакшена
|
| 2626 |
# host='0.0.0.0' для доступа из сети
|
| 2627 |
-
app.run(debug=True, host='0.0.0.0', port=7860)
|
|
|
|
| 1 |
+
|
| 2 |
# Импортируем необходимые библиотеки
|
| 3 |
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
|
| 4 |
import json
|
|
|
|
| 360 |
logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True)
|
| 361 |
flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger")
|
| 362 |
|
| 363 |
+
# --- GET запрос ---
|
| 364 |
page_title = "Закуп материалов"
|
| 365 |
page_content = PROCUREMENT_CONTENT # Используем переменную с контентом
|
| 366 |
page_scripts = PROCUREMENT_SCRIPTS # Используем переменную со скриптами
|
| 367 |
+
|
| 368 |
+
# Заменяем маркеры в базовом шаблоне
|
| 369 |
+
html = BASE_TEMPLATE.replace('__TITLE__', page_title)
|
| 370 |
+
html = html.replace('__CONTENT__', page_content)
|
| 371 |
+
html = html.replace('__SCRIPTS__', page_scripts)
|
| 372 |
+
|
| 373 |
+
# Передаем нужные переменные в render_template_string для рендеринга Jinja внутри контента
|
| 374 |
+
return render_template_string(html, categories=categories)
|
| 375 |
|
| 376 |
|
| 377 |
# 2. Маршрут "Раскрой"
|
|
|
|
| 501 |
logging.error(f"Ошибка при обработке раскроя: {e}", exc_info=True)
|
| 502 |
flash(f"Произошла внутренняя ошибка при обработке раскроя: {e}", "danger")
|
| 503 |
|
| 504 |
+
# --- GET запрос ---
|
| 505 |
# Передаем материалы, преобразовав количество в Decimal для отображения
|
| 506 |
fabrics_dec = []
|
| 507 |
for f in fabrics:
|
| 508 |
f_copy = f.copy()
|
| 509 |
+
f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа
|
| 510 |
fabrics_dec.append(f_copy)
|
| 511 |
|
| 512 |
fittings_dec = []
|
| 513 |
for f in fittings:
|
| 514 |
f_copy = f.copy()
|
| 515 |
+
f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа
|
| 516 |
fittings_dec.append(f_copy)
|
| 517 |
|
| 518 |
page_title = "Раскрой ткани"
|
| 519 |
page_content = CUTTING_CONTENT
|
| 520 |
page_scripts = CUTTING_SCRIPTS
|
| 521 |
+
|
| 522 |
+
html = BASE_TEMPLATE.replace('__TITLE__', page_title)
|
| 523 |
+
html = html.replace('__CONTENT__', page_content)
|
| 524 |
+
html = html.replace('__SCRIPTS__', page_scripts)
|
| 525 |
+
|
| 526 |
+
# Передаем переменные в render_template_string
|
| 527 |
+
return render_template_string(html, fabrics=fabrics_dec, fittings=fittings_dec)
|
| 528 |
|
| 529 |
|
| 530 |
# 3. Маршрут "Пошив"
|
|
|
|
| 712 |
'fabric_id': cutting_task['fabric_id'], # Сохраняем для справки
|
| 713 |
'fabric_name': cutting_task['fabric_name'],
|
| 714 |
'fittings_consumed': fittings_consumed_list, # Фактически использовано на пошив
|
| 715 |
+
'defects_reported': [], # Заполним ниже, преобразовав Decimal в строки
|
| 716 |
'status': 'pending_qc', # 'pending_qc', 'completed' (packed)
|
| 717 |
'timestamp_created': current_time,
|
| 718 |
'timestamp_completed': None, # Заполнится при ОТК
|
|
|
|
| 720 |
'qc_defective_quantity': 0
|
| 721 |
}
|
| 722 |
|
| 723 |
+
# Присваиваем ID задания на пошив и преобразуем Decimal в строку для JSON
|
| 724 |
for defect in defect_log_list:
|
| 725 |
defect['sewing_task_id'] = new_sewing_task['id']
|
| 726 |
# Преобразуем Decimal обратно в строку для JSON, если это ткань
|
| 727 |
+
if isinstance(defect['quantity'], Decimal):
|
| 728 |
defect['quantity'] = str(defect['quantity'])
|
| 729 |
+
# Добавляем в список задания
|
| 730 |
+
new_sewing_task['defects_reported'].append(defect)
|
| 731 |
|
| 732 |
|
| 733 |
+
# 5. Обновление статуса задания на раскрой
|
|
|
|
| 734 |
task_updated = False
|
| 735 |
for i, task in enumerate(data.get('cutting_tasks', [])):
|
| 736 |
if task.get('id') == cutting_task_id:
|
|
|
|
| 746 |
if 'sewing_tasks' not in data: data['sewing_tasks'] = []
|
| 747 |
data['sewing_tasks'].append(new_sewing_task)
|
| 748 |
|
| 749 |
+
if defect_log_list: # Если был брак
|
| 750 |
if 'defect_log' not in data: data['defect_log'] = []
|
| 751 |
+
# Добавляем уже обработанные записи из new_sewing_task['defects_reported']
|
| 752 |
+
data['defect_log'].extend(new_sewing_task['defects_reported'])
|
| 753 |
|
| 754 |
# Обновляем основной список материалов
|
| 755 |
data['materials'] = materials_list
|
|
|
|
| 765 |
logging.error(f"Ошибка при обработке пошива: {e}", exc_info=True)
|
| 766 |
flash(f"Произошла внутренняя ошибка при обработке пошива: {e}", "danger")
|
| 767 |
|
| 768 |
+
# --- GET запрос ---
|
| 769 |
# Преобразуем данные для шаблона
|
| 770 |
tasks_for_template = []
|
| 771 |
all_materials_dict = {m['id']: m for m in data.get('materials', [])} # Словарь для быстрого поиска остатков
|
| 772 |
|
| 773 |
for task in pending_cutting_tasks:
|
| 774 |
task_copy = task.copy()
|
| 775 |
+
# Преобразуем числа для отображения и data-атрибутов
|
| 776 |
+
task_copy['cut_items_quantity_int'] = int(task_copy.get('cut_items_quantity', 0))
|
| 777 |
+
task_copy['fabric_used_decimal'] = to_decimal(task_copy.get('fabric_used', '0'))
|
| 778 |
# Получаем текущие остатки фурнитуры для отображения в деталях
|
| 779 |
if 'required_fittings' in task_copy:
|
| 780 |
for fitting in task_copy['required_fittings']:
|
| 781 |
mat_data = all_materials_dict.get(fitting['fitting_id'])
|
| 782 |
fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0')
|
| 783 |
fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт'
|
| 784 |
+
fitting['quantity_needed_int'] = int(fitting.get('quantity_needed',0)) # Преобразуем для JSON
|
| 785 |
tasks_for_template.append(task_copy)
|
| 786 |
|
| 787 |
# Получаем все материалы для выбора брака (с преобразованными количествами)
|
|
|
|
| 795 |
page_title = "Пошив изделий"
|
| 796 |
page_content = SEWING_CONTENT
|
| 797 |
page_scripts = SEWING_SCRIPTS
|
| 798 |
+
|
| 799 |
+
html = BASE_TEMPLATE.replace('__TITLE__', page_title)
|
| 800 |
+
html = html.replace('__CONTENT__', page_content)
|
| 801 |
+
html = html.replace('__SCRIPTS__', page_scripts)
|
| 802 |
+
|
| 803 |
+
return render_template_string(html,
|
| 804 |
cutting_tasks=tasks_for_template,
|
| 805 |
all_materials=all_materials_dec) # Передаем материалы с quantity_decimal
|
| 806 |
|
|
|
|
| 872 |
data['qc_packing_items'].append(packed_item)
|
| 873 |
|
| 874 |
# Регистрация брака на этапе ОТК (если есть)
|
|
|
|
| 875 |
if quantity_defective > 0:
|
| 876 |
defect_log_entry = {
|
| 877 |
'log_id': uuid.uuid4().hex,
|
|
|
|
| 894 |
task_updated = False
|
| 895 |
for i, task in enumerate(data.get('sewing_tasks', [])):
|
| 896 |
if task.get('id') == sewing_task_id:
|
| 897 |
+
# Обновляем информацию о результате ОТК в само задание
|
| 898 |
+
# Убедимся, что читаем существующие значения как int
|
| 899 |
+
packed_before = int(task.get('qc_packed_quantity', 0))
|
| 900 |
+
defective_before = int(task.get('qc_defective_quantity', 0))
|
| 901 |
+
|
| 902 |
+
task['qc_packed_quantity'] = packed_before + quantity_packed
|
| 903 |
+
task['qc_defective_quantity'] = defective_before + quantity_defective
|
| 904 |
task['status'] = 'completed' # Считаем завершенным после обработки
|
| 905 |
task['timestamp_completed'] = current_time # Время последнего ОТК
|
| 906 |
task_updated = True
|
|
|
|
| 918 |
logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True)
|
| 919 |
flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger")
|
| 920 |
|
| 921 |
+
# --- GET запрос ---
|
| 922 |
page_title = "ОТК и Упаковка"
|
| 923 |
page_content = QC_PACKING_CONTENT
|
| 924 |
page_scripts = QC_PACKING_SCRIPTS
|
| 925 |
+
|
| 926 |
+
html = BASE_TEMPLATE.replace('__TITLE__', page_title)
|
| 927 |
+
html = html.replace('__CONTENT__', page_content)
|
| 928 |
+
html = html.replace('__SCRIPTS__', page_scripts)
|
| 929 |
+
|
| 930 |
# Передаем задачи, ожидающие ОТК, в шаблон
|
| 931 |
+
return render_template_string(html, sewing_tasks=pending_sewing_tasks)
|
| 932 |
|
| 933 |
|
| 934 |
# 5. Маршрут "Админ-панель"
|
|
|
|
| 969 |
# Используем find_sewing_task_by_id, чтобы получить уже преобразованные числа
|
| 970 |
task_data = find_sewing_task_by_id(t['id'])
|
| 971 |
if task_data: # Убедимся, что задача найдена
|
| 972 |
+
# Преобразуем Decimal в строку для дефектов ткани перед передачей в шаблон
|
| 973 |
+
if 'defects_reported' in task_data:
|
| 974 |
+
for defect in task_data['defects_reported']:
|
| 975 |
+
if isinstance(defect.get('quantity'), Decimal):
|
| 976 |
+
defect['quantity'] = str(defect['quantity'])
|
| 977 |
sewing_tasks_view.append(task_data)
|
| 978 |
else:
|
| 979 |
logging.warning(f"Задача на пошив с ID {t['id']} не найдена при подготовке для админки.")
|
|
|
|
| 991 |
packed_items_view.append(item_copy)
|
| 992 |
|
| 993 |
defect_log_view = []
|
| 994 |
+
# total_defect_items = 0 # Будем считать штуки, метры отдельно
|
| 995 |
total_defect_fabric_m = Decimal('0.00')
|
| 996 |
total_defect_fittings_pcs = 0
|
| 997 |
total_defect_finished_pcs = 0
|
|
|
|
| 1023 |
page_title = "Админ-панель"
|
| 1024 |
page_content = ADMIN_CONTENT
|
| 1025 |
page_scripts = ADMIN_SCRIPTS # Если есть специфичные скрипты для админки
|
| 1026 |
+
|
| 1027 |
+
html = BASE_TEMPLATE.replace('__TITLE__', page_title)
|
| 1028 |
+
html = html.replace('__CONTENT__', page_content)
|
| 1029 |
+
html = html.replace('__SCRIPTS__', page_scripts)
|
| 1030 |
|
| 1031 |
# Передаем подготовленные данные
|
| 1032 |
return render_template_string(
|
| 1033 |
+
html,
|
| 1034 |
materials=materials_view,
|
| 1035 |
cutting_tasks=cutting_tasks_view,
|
| 1036 |
sewing_tasks=sewing_tasks_view,
|
|
|
|
| 1162 |
|
| 1163 |
# --- HTML Шаблоны (как строки Python) ---
|
| 1164 |
|
| 1165 |
+
# Базовый шаблон с уникальными маркерами вместо .format() плейсхолдеров
|
| 1166 |
BASE_TEMPLATE = """
|
| 1167 |
<!DOCTYPE html>
|
| 1168 |
<html lang="ru">
|
| 1169 |
<head>
|
| 1170 |
<meta charset="UTF-8">
|
| 1171 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1172 |
+
<title>__TITLE__ - Текстиль Учет</title>
|
| 1173 |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
| 1174 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1175 |
<style>
|
| 1176 |
+
body {
|
| 1177 |
background-color: #f8f9fa; /* Светлый фон */
|
| 1178 |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 1179 |
padding-top: 70px; /* Отступ для фиксированной навигации */
|
| 1180 |
padding-bottom: 60px; /* Отступ для футера */
|
| 1181 |
position: relative;
|
| 1182 |
min-height: 100vh;
|
| 1183 |
+
}
|
| 1184 |
+
.navbar {
|
| 1185 |
background-color: #343a40; /* Темная навигация */
|
| 1186 |
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
| 1187 |
+
}
|
| 1188 |
+
.navbar-brand, .nav-link {
|
| 1189 |
color: #f8f9fa !important; /* Светлый текст */
|
| 1190 |
+
}
|
| 1191 |
+
.navbar-brand:hover, .nav-link:hover {
|
| 1192 |
color: #adb5bd !important; /* Чуть темнее при наведении */
|
| 1193 |
+
}
|
| 1194 |
+
.nav-link.active {
|
| 1195 |
font-weight: bold;
|
| 1196 |
color: #ffffff !important;
|
| 1197 |
border-bottom: 2px solid #0d6efd; /* Подчеркивание активного пункта */
|
| 1198 |
+
}
|
| 1199 |
+
.card {
|
| 1200 |
margin-bottom: 1.5rem;
|
| 1201 |
border: none; /* Убираем стандартную рамку */
|
| 1202 |
border-radius: 0.5rem; /* Скругление углов */
|
| 1203 |
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); /* Тень */
|
| 1204 |
transition: transform 0.2s ease-in-out;
|
| 1205 |
+
}
|
| 1206 |
+
.card:hover {
|
| 1207 |
/* transform: translateY(-3px); Легкий подъем при наведении - убрал, может мешать */
|
| 1208 |
+
}
|
| 1209 |
+
.card-header {
|
| 1210 |
background-color: #6c757d; /* Серая шапка */
|
| 1211 |
color: white;
|
| 1212 |
font-weight: 600;
|
|
|
|
| 1215 |
border-top-right-radius: 0.5rem;
|
| 1216 |
display: flex;
|
| 1217 |
align-items: center;
|
| 1218 |
+
}
|
| 1219 |
+
.card-header i {
|
| 1220 |
margin-right: 0.5rem; /* Отступ для иконки в шапке */
|
| 1221 |
+
}
|
| 1222 |
+
.btn-primary {
|
| 1223 |
background-color: #0d6efd;
|
| 1224 |
border-color: #0d6efd;
|
| 1225 |
+
}
|
| 1226 |
+
.btn-primary:hover {
|
| 1227 |
background-color: #0b5ed7;
|
| 1228 |
border-color: #0a58ca;
|
| 1229 |
+
}
|
| 1230 |
+
.btn-success {
|
| 1231 |
background-color: #198754;
|
| 1232 |
border-color: #198754;
|
| 1233 |
+
}
|
| 1234 |
+
.btn-danger {
|
| 1235 |
background-color: #dc3545;
|
| 1236 |
border-color: #dc3545;
|
| 1237 |
+
}
|
| 1238 |
+
.btn-warning {
|
| 1239 |
background-color: #ffc107;
|
| 1240 |
border-color: #ffc107;
|
| 1241 |
color: #212529; /* Темный текст для желтой кнопки */
|
| 1242 |
+
}
|
| 1243 |
+
.btn-info {
|
| 1244 |
background-color: #0dcaf0;
|
| 1245 |
border-color: #0dcaf0;
|
| 1246 |
color: #000;
|
| 1247 |
+
}
|
| 1248 |
+
.table-hover tbody tr:hover {
|
| 1249 |
background-color: rgba(0, 0, 0, 0.05); /* Легкое выделение строки таблицы */
|
| 1250 |
cursor: pointer; /* Намек на интерактивность (если будет) */
|
| 1251 |
+
}
|
| 1252 |
+
.container-main { /* Переименовал, чтобы не конфликтовать с Bootstrap */
|
| 1253 |
max-width: 1400px; /* Чуть шире контейнер */
|
| 1254 |
margin: 0 auto; /* Центрирование */
|
| 1255 |
padding-left: 15px;
|
| 1256 |
padding-right: 15px;
|
| 1257 |
+
}
|
| 1258 |
/* Статусы */
|
| 1259 |
+
.status-pending { color: #fd7e14; font-weight: bold; } /* Оранжевый - ожидает */
|
| 1260 |
+
.status-completed { color: #198754; font-weight: bold; } /* Зеленый - завершено */
|
| 1261 |
+
.status-pending_qc { color: #ffc107; font-weight: bold; } /* Желтый - ожидает ОТК */
|
| 1262 |
|
| 1263 |
+
.flash-messages .alert {
|
| 1264 |
margin-bottom: 1rem;
|
| 1265 |
border-radius: 0.5rem;
|
| 1266 |
+
}
|
| 1267 |
/* Стиль для динамически добавляемых строк */
|
| 1268 |
+
.dynamic-row, .dynamic-fitting-row, .dynamic-defect-row {
|
| 1269 |
border: 1px solid #eee;
|
| 1270 |
padding: 15px;
|
| 1271 |
margin-bottom: 15px;
|
| 1272 |
border-radius: 5px;
|
| 1273 |
background-color: #fdfdfd;
|
| 1274 |
position: relative; /* Для позиционирования кнопки удаления */
|
| 1275 |
+
}
|
| 1276 |
+
.remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn {
|
| 1277 |
position: absolute;
|
| 1278 |
top: 10px;
|
| 1279 |
right: 10px;
|
| 1280 |
+
z-index: 10; /* Чтобы кнопка была поверх инпутов */
|
| 1281 |
+
}
|
| 1282 |
/* Responsive table */
|
| 1283 |
+
.table-responsive {
|
| 1284 |
margin-bottom: 1rem;
|
| 1285 |
+
}
|
| 1286 |
+
.table th, .table td {
|
| 1287 |
vertical-align: middle; /* Выравнивание по центру */
|
| 1288 |
+
font-size: 0.9rem; /* Уменьшил шрифт в таблицах */
|
| 1289 |
+
}
|
| 1290 |
+
.table td small {
|
| 1291 |
color: #6c757d; /* Серый цвет для мелкого текста */
|
| 1292 |
display: block; /* ID на новой строке */
|
| 1293 |
+
font-size: 0.75rem;
|
| 1294 |
+
}
|
| 1295 |
+
.table .badge { /* Стили для бэйджей в таблицах */
|
| 1296 |
font-size: 0.8em;
|
| 1297 |
+
}
|
| 1298 |
+
.table th i.fa-sort,
|
| 1299 |
+
.table th i.fa-sort-up,
|
| 1300 |
+
.table th i.fa-sort-down {
|
| 1301 |
+
margin-left: 5px;
|
| 1302 |
+
color: #adb5bd;
|
| 1303 |
+
cursor: pointer;
|
| 1304 |
+
}
|
| 1305 |
+
.table th:hover i {
|
| 1306 |
+
color: #343a40;
|
| 1307 |
+
}
|
| 1308 |
/* Footer */
|
| 1309 |
+
.footer {
|
| 1310 |
position: absolute;
|
| 1311 |
bottom: 0;
|
| 1312 |
width: 100%;
|
|
|
|
| 1315 |
text-align: center;
|
| 1316 |
padding: 10px 0;
|
| 1317 |
font-size: 0.9em;
|
| 1318 |
+
}
|
| 1319 |
/* Скрытие стрелок у number инпутов */
|
| 1320 |
input[type=number]::-webkit-inner-spin-button,
|
| 1321 |
+
input[type=number]::-webkit-outer-spin-button {
|
| 1322 |
-webkit-appearance: none;
|
| 1323 |
margin: 0;
|
| 1324 |
+
}
|
| 1325 |
+
input[type=number] {
|
| 1326 |
-moz-appearance: textfield; /* Firefox */
|
| 1327 |
+
}
|
| 1328 |
+
.form-text {
|
| 1329 |
font-size: 0.8rem;
|
| 1330 |
+
}
|
| 1331 |
</style>
|
| 1332 |
</head>
|
| 1333 |
<body>
|
|
|
|
| 1357 |
</li>
|
| 1358 |
</ul>
|
| 1359 |
<!-- Кнопки Hugging Face справа -->
|
| 1360 |
+
<div class="d-flex align-items-center"> {# Выравнивание по центру #}
|
| 1361 |
+
<form method="POST" action="{{ url_for('backup_hf') }}" class="me-2 mb-0"> {# Убрал нижний маргин #}
|
| 1362 |
<button type="submit" class="btn btn-sm btn-outline-light" title="Создать резервную копию на Hugging Face">
|
| 1363 |
<i class="fas fa-cloud-upload-alt"></i> Backup
|
| 1364 |
</button>
|
| 1365 |
</form>
|
| 1366 |
+
<form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Это перезапишет локальные данные последней версией с Hugging Face. Вы уверены?');" class="mb-0"> {# Убрал нижний маргин #}
|
| 1367 |
<button type="submit" class="btn btn-sm btn-outline-warning" title="Скачать базу данных с Hugging Face (перезапишет локальную)">
|
| 1368 |
<i class="fas fa-cloud-download-alt"></i> Download DB
|
| 1369 |
</button>
|
|
|
|
| 1379 |
{% if messages %}
|
| 1380 |
<div class="flash-messages">
|
| 1381 |
{% for category, message in messages %}
|
| 1382 |
+
{# Используем стандартные классы bootstrap для категорий flash #}
|
| 1383 |
+
{% set alert_class = 'alert-info' %} {# Default #}
|
| 1384 |
+
{% if category == 'danger' %} {% set alert_class = 'alert-danger' %}
|
| 1385 |
+
{% elif category == 'success' %} {% set alert_class = 'alert-success' %}
|
| 1386 |
+
{% elif category == 'warning' %} {% set alert_class = 'alert-warning' %}
|
| 1387 |
+
{% endif %}
|
| 1388 |
+
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
|
| 1389 |
{{ message }}
|
| 1390 |
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 1391 |
</div>
|
|
|
|
| 1395 |
{% endwith %}
|
| 1396 |
|
| 1397 |
{# Основной контент страницы #}
|
| 1398 |
+
__CONTENT__
|
| 1399 |
|
| 1400 |
</div>
|
| 1401 |
|
|
|
|
| 1407 |
|
| 1408 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
| 1409 |
{# Скрипты для конкретной страницы #}
|
| 1410 |
+
__SCRIPTS__
|
| 1411 |
</body>
|
| 1412 |
</html>
|
| 1413 |
"""
|
| 1414 |
|
| 1415 |
+
# --- Контент и скрипты для страниц (остаются без изменений) ---
|
| 1416 |
|
| 1417 |
PROCUREMENT_CONTENT = """
|
| 1418 |
<div class="card">
|
|
|
|
| 1483 |
function addRow() {
|
| 1484 |
const container = document.getElementById('material-rows');
|
| 1485 |
const firstRow = container.querySelector('.dynamic-row'); // Берем первую строку как шаблон
|
| 1486 |
+
if (!firstRow) { // Если строк нет, возможно, создать базовую? Или просто ничего не делать.
|
| 1487 |
+
console.warn("No template row found to clone.");
|
| 1488 |
+
return;
|
| 1489 |
+
}
|
| 1490 |
const newRow = firstRow.cloneNode(true);
|
| 1491 |
|
| 1492 |
// Очищаем значения в новой строке
|
|
|
|
| 1521 |
|
| 1522 |
container.appendChild(newRow);
|
| 1523 |
attachCategoryChangeEvent(newRow); // Добавляем обработчик для новой строки
|
|
|
|
| 1524 |
}
|
| 1525 |
|
| 1526 |
function removeRow(button) {
|
| 1527 |
const row = button.closest('.dynamic-row');
|
| 1528 |
+
// Позволяем удалять любую строку
|
| 1529 |
if (row) {
|
| 1530 |
row.remove();
|
| 1531 |
}
|
|
|
|
| 1564 |
|
| 1565 |
// Инициализация при загрузке страницы
|
| 1566 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1567 |
+
// Если изначально нет строк, добавим одну пустую
|
| 1568 |
+
if (!document.querySelector('#material-rows .dynamic-row')) {
|
| 1569 |
+
// Не добавляем автоматически, пользователь нажмет кнопку сам
|
| 1570 |
+
} else {
|
| 1571 |
+
document.querySelectorAll('.dynamic-row').forEach(row => {
|
| 1572 |
+
attachCategoryChangeEvent(row);
|
| 1573 |
+
const categorySelect = row.querySelector('.category-select');
|
| 1574 |
+
if (categorySelect) handleCategoryChange(categorySelect); // Инициализируем видимость поля новой категории
|
| 1575 |
+
});
|
| 1576 |
+
}
|
| 1577 |
});
|
| 1578 |
</script>
|
| 1579 |
"""
|
|
|
|
| 1653 |
if (selectedOption && selectedOption.value) {
|
| 1654 |
const quantity = selectedOption.getAttribute('data-quantity'); // Уже содержит запятую, если надо
|
| 1655 |
const unit = selectedOption.getAttribute('data-unit');
|
| 1656 |
+
if (availableDiv) availableDiv.textContent = `В наличии: ${quantity} ${unit}`;
|
| 1657 |
+
if (unitSpan) unitSpan.textContent = unit; // Обновляем единицу измерения в поле расхода
|
| 1658 |
} else {
|
| 1659 |
+
if (availableDiv) availableDiv.textContent = '';
|
| 1660 |
+
if (unitSpan) unitSpan.textContent = 'м'; // Сброс на метры по умолчанию
|
| 1661 |
}
|
| 1662 |
}
|
| 1663 |
|
|
|
|
| 1730 |
// Инициализация при загрузке
|
| 1731 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1732 |
updateAvailableQuantity(); // Вызываем при загрузк��
|
| 1733 |
+
const fittingRows = document.querySelectorAll('.dynamic-fitting-row');
|
| 1734 |
+
fittingRows.forEach(row => {
|
| 1735 |
attachFittingChangeEvent(row);
|
| 1736 |
const fittingSelect = row.querySelector('.fitting-select');
|
| 1737 |
if (fittingSelect) handleFittingChange(fittingSelect); // Инициализируем показ доступности
|
| 1738 |
});
|
| 1739 |
+
// Если нет строк фурнитуры ИЗНАЧАЛЬНО (например, после ошибки формы),
|
| 1740 |
+
// гарантируем, что хотя бы одна строка есть для добавления.
|
| 1741 |
+
// Но не добавляем, если форма загрузилась чисто.
|
| 1742 |
+
if (fittingRows.length === 0 && document.getElementById('fittings-rows')) {
|
| 1743 |
+
// Можно раскомментировать, если нужна хотя бы одна строка всегда
|
| 1744 |
+
// addFittingRow();
|
| 1745 |
}
|
| 1746 |
});
|
| 1747 |
</script>
|
|
|
|
| 1854 |
|
| 1855 |
|
| 1856 |
if (selectedOption && selectedOption.value) {
|
| 1857 |
+
const fabricName = selectedOption.getAttribute('data-fabric-name') || 'N/A';
|
| 1858 |
+
const cutQuantity = selectedOption.getAttribute('data-cut-quantity') || '0';
|
| 1859 |
|
| 1860 |
+
if (document.getElementById('detail-fabric-name')) document.getElementById('detail-fabric-name').textContent = fabricName;
|
| 1861 |
+
if (document.getElementById('detail-cut-quantity')) document.getElementById('detail-cut-quantity').textContent = cutQuantity;
|
| 1862 |
+
if (document.getElementById('detail-fabric-used')) document.getElementById('detail-fabric-used').textContent = selectedOption.getAttribute('data-fabric-used') || 'N/A';
|
| 1863 |
|
| 1864 |
+
if(maxSewnSpan) maxSewnSpan.textContent = cutQuantity;
|
| 1865 |
if(sewnInput) sewnInput.max = cutQuantity; // Устанавливаем максимум для поля ввода
|
| 1866 |
|
| 1867 |
// Предзаполняем название изделия на основе ткани, если поле пустое
|
|
|
|
| 1871 |
|
| 1872 |
|
| 1873 |
const fittingsList = document.getElementById('detail-fittings-list');
|
| 1874 |
+
if(fittingsList) {
|
| 1875 |
+
fittingsList.innerHTML = ''; // Очищаем список
|
| 1876 |
+
try {
|
| 1877 |
+
const fittingsData = JSON.parse(selectedOption.getAttribute('data-fittings') || '[]');
|
| 1878 |
+
|
| 1879 |
+
if (fittingsData && fittingsData.length > 0) {
|
| 1880 |
+
fittingsData.forEach(fitting => {
|
| 1881 |
+
// Ищем фурнитуру в селекте брака, чтобы показать остаток
|
| 1882 |
+
let availableQtyStr = 'Н/Д';
|
| 1883 |
+
const defectSelect = document.querySelector('.defect-material-select'); // Берем любой селект для поиска
|
| 1884 |
+
if (defectSelect) {
|
| 1885 |
+
const option = defectSelect.querySelector(`option[value="${fitting.fitting_id}"]`);
|
| 1886 |
+
if (option) {
|
| 1887 |
+
availableQtyStr = `${option.getAttribute('data-quantity')} ${option.getAttribute('data-unit')}`;
|
| 1888 |
+
}
|
| 1889 |
+
} else {
|
| 1890 |
+
// Если селекта брака нет, инфо об остатках не показать
|
| 1891 |
+
availableQtyStr = '(см. склад)';
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
const listItem = document.createElement('li');
|
| 1895 |
+
listItem.textContent = `${fitting.fitting_name || '?'}: ${fitting.quantity_needed_int || '?'} шт. (Доступно: ${availableQtyStr})`;
|
| 1896 |
+
fittingsList.appendChild(listItem);
|
| 1897 |
+
});
|
| 1898 |
} else {
|
| 1899 |
+
const listItem = document.createElement('li');
|
| 1900 |
+
listItem.textContent = 'Фурнитура не указана в задании на раскрой.';
|
| 1901 |
+
listItem.classList.add('text-muted');
|
| 1902 |
+
fittingsList.appendChild(listItem);
|
| 1903 |
}
|
| 1904 |
+
} catch (e) {
|
| 1905 |
+
console.error("Error parsing fittings data:", e);
|
| 1906 |
+
const listItem = document.createElement('li');
|
| 1907 |
+
listItem.textContent = 'Ошибка загрузки данных фурнитуры.';
|
| 1908 |
+
listItem.classList.add('text-danger');
|
| 1909 |
+
fittingsList.appendChild(listItem);
|
| 1910 |
+
}
|
| 1911 |
+
} // end if fittingsList
|
|
|
|
|
|
|
|
|
|
| 1912 |
|
| 1913 |
if(detailsDiv) detailsDiv.style.display = 'block';
|
| 1914 |
} else {
|
|
|
|
| 1934 |
newRow.querySelectorAll('input[type="text"]').forEach(input => input.value = '');
|
| 1935 |
const availabilityDiv = newRow.querySelector('.defect-availability');
|
| 1936 |
if(availabilityDiv) availabilityDiv.textContent = '';
|
| 1937 |
+
// Сброс инпута количества
|
| 1938 |
+
const qtyInput = newRow.querySelector('.defect-quantity-input');
|
| 1939 |
+
if(qtyInput){
|
| 1940 |
+
qtyInput.inputMode = 'text';
|
| 1941 |
+
qtyInput.placeholder = 'Количество';
|
| 1942 |
+
qtyInput.step = 'any';
|
| 1943 |
+
}
|
| 1944 |
+
|
| 1945 |
|
| 1946 |
// Кнопка удаления
|
| 1947 |
const removeBtn = newRow.querySelector('.remove-defect-row-btn');
|
|
|
|
| 2008 |
// Инициализация
|
| 2009 |
document.addEventListener('DOMContentLoaded', () => {
|
| 2010 |
showTaskDetails(); // Показать детали для выбранного по умолчанию (если есть)
|
| 2011 |
+
const defectRows = document.querySelectorAll('.dynamic-defect-row');
|
| 2012 |
+
defectRows.forEach(row => {
|
| 2013 |
attachDefectChangeEvent(row);
|
| 2014 |
const defectSelect = row.querySelector('.defect-material-select');
|
| 2015 |
if (defectSelect) handleDefectChange(defectSelect); // Инициализируем доступность и тип инпута
|
| 2016 |
});
|
| 2017 |
+
// Если нет строк брака ИЗНАЧАЛЬНО, гарантируем наличие одной строки
|
| 2018 |
+
if (defectRows.length === 0 && document.getElementById('defect-rows')) {
|
| 2019 |
+
// Не добавляем автоматически
|
| 2020 |
+
// addDefectRow();
|
| 2021 |
+
}
|
| 2022 |
});
|
| 2023 |
</script>
|
| 2024 |
"""
|
|
|
|
| 2076 |
|
| 2077 |
function updateQcQuantities() {
|
| 2078 |
const select = document.getElementById('sewing_task_id');
|
| 2079 |
+
const selectedOption = select ? select.options[select.selectedIndex] : null;
|
| 2080 |
const packedInput = document.getElementById('quantity_packed');
|
| 2081 |
const defectiveInput = document.getElementById('quantity_defective');
|
| 2082 |
const totalInfo = document.getElementById('qc-total-info');
|
|
|
|
| 2094 |
}
|
| 2095 |
if(totalInfo) {
|
| 2096 |
totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака не должна превышать это число.`;
|
| 2097 |
+
totalInfo.classList.remove('text-danger', 'text-success');
|
| 2098 |
}
|
| 2099 |
// Вызываем валидацию сразу
|
| 2100 |
validateQcSum();
|
|
|
|
| 2109 |
defectiveInput.max = '';
|
| 2110 |
defectiveInput.value = '0';
|
| 2111 |
}
|
| 2112 |
+
if(totalInfo) totalInfo.textContent = 'Выберите задание на пошив.';
|
| 2113 |
}
|
| 2114 |
}
|
| 2115 |
|
|
|
|
| 2124 |
const defective = parseInt(defectiveInput.value) || 0;
|
| 2125 |
const totalProcessed = packed + defective;
|
| 2126 |
|
| 2127 |
+
totalInfo.classList.remove('text-danger', 'text-success'); // Сброс классов
|
| 2128 |
+
|
| 2129 |
if (maxAllowedQuantity === 0 && totalProcessed > 0) {
|
| 2130 |
// Если задание не выбрано, но что-то введено
|
| 2131 |
totalInfo.textContent = 'Сначала выберите задание на пошив!';
|
| 2132 |
totalInfo.classList.add('text-danger');
|
| 2133 |
return false;
|
| 2134 |
}
|
| 2135 |
+
if (maxAllowedQuantity === 0 && totalProcessed === 0) {
|
| 2136 |
+
totalInfo.textContent = 'Выберите задание на пошив.';
|
| 2137 |
+
return false; // Не валидно, если задание не выбрано
|
| 2138 |
+
}
|
| 2139 |
+
|
| 2140 |
|
| 2141 |
if (totalProcessed > maxAllowedQuantity) {
|
| 2142 |
totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает количество сшитых (${maxAllowedQuantity})!`;
|
| 2143 |
totalInfo.classList.add('text-danger');
|
| 2144 |
return false; // Сумма неверна
|
| 2145 |
+
} else if (totalProcessed === 0) {
|
| 2146 |
+
totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Укажите кол-во упакованных или брака.`;
|
| 2147 |
+
// Можно не считать ошибкой, но форма не должна отправляться
|
| 2148 |
+
return false; // Не валидно, если сумма 0
|
| 2149 |
+
}
|
| 2150 |
+
else {
|
| 2151 |
+
totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака (${totalProcessed}) корректна.`;
|
| 2152 |
+
totalInfo.classList.add('text-success'); // Показываем успех
|
| 2153 |
return true; // Сумма верна
|
| 2154 |
}
|
| 2155 |
}
|
| 2156 |
|
| 2157 |
function validateQcForm() {
|
| 2158 |
// Дополнительная проверка перед отправкой формы
|
| 2159 |
+
const isValidSum = validateQcSum(); // Выполняем проверку и обновляем сообщение
|
| 2160 |
if (!isValidSum) {
|
| 2161 |
+
alert('Ошибка в количестве упакованных или бракованных изделий. Проверьте введенные значения и сообщение под полями ввода.');
|
| 2162 |
return false; // Предотвратить отправку формы
|
| 2163 |
}
|
| 2164 |
|
| 2165 |
+
// Проверка, что выбрано задание
|
| 2166 |
+
const select = document.getElementById('sewing_task_id');
|
| 2167 |
+
if (!select || !select.value) {
|
| 2168 |
+
alert('Пожалуйста, выберите задание на пошив.');
|
| 2169 |
+
return false;
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
// Проверка, что хотя бы одно поле больше нуля (уже делается в validateQcSum)
|
| 2173 |
const packedInput = document.getElementById('quantity_packed');
|
| 2174 |
const defectiveInput = document.getElementById('quantity_defective');
|
| 2175 |
const packed = parseInt(packedInput.value) || 0;
|
| 2176 |
const defective = parseInt(defectiveInput.value) || 0;
|
|
|
|
| 2177 |
if (packed === 0 && defective === 0) {
|
| 2178 |
alert('Укажите количество упакованных или бракованных изделий (хотя бы одно должно быть больше н��ля).');
|
| 2179 |
return false;
|
| 2180 |
}
|
| 2181 |
|
|
|
|
| 2182 |
return true; // Разрешить отправку формы
|
| 2183 |
}
|
| 2184 |
|
|
|
|
| 2357 |
<tr class="material-row" data-name="{{ material.name|lower }}" data-category="{{ material.category|default('Без категории')|lower }}">
|
| 2358 |
<td title="{{ material.id }}"><small>{{ material.id[:8] }}...</small></td>
|
| 2359 |
<td>{{ material.name }}</td>
|
| 2360 |
+
<td><span class="badge bg-info text-dark">{{ material.category | default('Без категории') }}</span></td>
|
| 2361 |
+
<td><span class="badge {{ 'bg-primary' if material.type == 'fabric' else 'bg-secondary' }}">{{ 'Ткань' if material.type == 'fabric' else 'Фурнитура' }}</span></td>
|
| 2362 |
{# Используем quantity_dec для отображения #}
|
| 2363 |
<td data-sort="{{ material.quantity_dec }}">{{ material.quantity_dec|string|replace('.', ',') }}</td>
|
| 2364 |
<td>{{ material.unit }}</td>
|
|
|
|
| 2568 |
<td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
|
| 2569 |
<td>{{ defect.quantity_view }}</td> {# Используем quantity_view #}
|
| 2570 |
<td>{{ defect.unit }}</td>
|
| 2571 |
+
<td><span class="badge {{ 'bg-warning text-dark' if defect.stage == 'qc_packing' else 'bg-danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td>
|
| 2572 |
<td>{{ defect.reason | default('Не указана') }}</td>
|
| 2573 |
<td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
|
| 2574 |
<td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
|
|
|
|
| 2592 |
const tableBody = document.getElementById('materials-table')?.querySelector('tbody'); // Добавил ? для безопасности
|
| 2593 |
|
| 2594 |
if (searchInput && tableBody) {
|
| 2595 |
+
searchInput.addEventListener('input', function() { // Используем 'input' для немедленной реакции
|
| 2596 |
+
const searchTerm = searchInput.value.toLowerCase().trim();
|
| 2597 |
const rows = tableBody.querySelectorAll('tr.material-row'); // Используем класс для точности
|
| 2598 |
|
| 2599 |
rows.forEach(row => {
|
|
|
|
| 2615 |
if (!table) return;
|
| 2616 |
const tbody = table.querySelector('tbody');
|
| 2617 |
if (!tbody) return;
|
| 2618 |
+
|
| 2619 |
+
// Сохраняем строки заголовка, если они есть (например, <thead>)
|
| 2620 |
+
const headerRow = table.querySelector('thead tr');
|
| 2621 |
+
if (!headerRow) return; // Нужна строка заголовка
|
| 2622 |
+
|
| 2623 |
const rows = Array.from(tbody.querySelectorAll('tr'));
|
| 2624 |
+
if (rows.length < 2) return; // Нечего сортировать
|
| 2625 |
+
|
| 2626 |
+
const headerCell = headerRow.querySelector(`th:nth-child(${columnIndex + 1})`); // +1 т.к. nth-child(1) - первый
|
| 2627 |
+
if (!headerCell) return; // Не найдена ячейка заголовка
|
| 2628 |
|
| 2629 |
// Определяем направление сортировки
|
| 2630 |
+
let currentDir = headerCell.dataset.sortDir || 'asc'; // Используем data-атрибут для хранения направления
|
| 2631 |
let newDir = currentDir === 'asc' ? 'desc' : 'asc';
|
| 2632 |
|
| 2633 |
+
// Сбрасываем направления у других колонок и иконки
|
| 2634 |
+
headerRow.querySelectorAll('th').forEach((th, index) => {
|
| 2635 |
+
const icon = th.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down');
|
| 2636 |
+
if (icon && index !== columnIndex) {
|
| 2637 |
+
icon.classList.remove('fa-sort-up', 'fa-sort-down');
|
| 2638 |
+
icon.classList.add('fa-sort');
|
| 2639 |
+
}
|
| 2640 |
+
th.dataset.sortDir = ''; // Сброс направления для всех, кроме текущей
|
| 2641 |
+
});
|
| 2642 |
// Устанавливаем новое направление для текущей колонки
|
| 2643 |
+
headerCell.dataset.sortDir = newDir;
|
| 2644 |
|
| 2645 |
|
| 2646 |
rows.sort((a, b) => {
|
| 2647 |
let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`);
|
| 2648 |
let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`);
|
| 2649 |
|
| 2650 |
+
// Используем data-sort атрибут если он есть, иначе текст ячейки
|
| 2651 |
let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : '';
|
| 2652 |
let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : '';
|
| 2653 |
|
|
|
|
| 2656 |
// Преобразуем в число, заменяя запятую на точку и обрабатывая ошибки
|
| 2657 |
valA = parseFloat(String(valA).replace(',', '.')) || 0;
|
| 2658 |
valB = parseFloat(String(valB).replace(',', '.')) || 0;
|
| 2659 |
+
// Для корректной сортировки чисел
|
| 2660 |
+
const comparison = valA - valB;
|
| 2661 |
+
return newDir === 'asc' ? comparison : -comparison;
|
| 2662 |
|
| 2663 |
} else {
|
| 2664 |
// Текстовое сравнение без учета регистра
|
| 2665 |
valA = valA.toLowerCase();
|
| 2666 |
valB = valB.toLowerCase();
|
| 2667 |
+
const comparison = valA.localeCompare(valB); // Используем localeCompare для корректной сортировки строк
|
| 2668 |
+
return newDir === 'asc' ? comparison : -comparison;
|
| 2669 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2670 |
});
|
| 2671 |
|
| 2672 |
// Удаляем старые строки и вставляем отсортированные
|
|
|
|
| 2675 |
}
|
| 2676 |
rows.forEach(row => tbody.appendChild(row));
|
| 2677 |
|
| 2678 |
+
// Обновляем иконку сортировки для текущей колонки
|
| 2679 |
+
const icon = headerCell.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down');
|
| 2680 |
+
if (icon) {
|
| 2681 |
+
icon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down');
|
| 2682 |
+
icon.classList.add(newDir === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
|
| 2683 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2684 |
}
|
| 2685 |
|
| 2686 |
+
// --- Активация табов Bootstrap ---
|
| 2687 |
document.addEventListener('DOMContentLoaded', function () {
|
| 2688 |
+
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button[data-bs-toggle="tab"]'))
|
| 2689 |
triggerTabList.forEach(function (triggerEl) {
|
| 2690 |
+
// Проверяем, существует ли уже экземпляр Tab
|
| 2691 |
+
var tabInstance = bootstrap.Tab.getInstance(triggerEl);
|
| 2692 |
+
if (!tabInstance) {
|
| 2693 |
+
tabTrigger = new bootstrap.Tab(triggerEl); // Создаем, только если нет
|
| 2694 |
+
} else {
|
| 2695 |
+
tabTrigger = tabInstance; // Используем существующий
|
| 2696 |
+
}
|
| 2697 |
|
| 2698 |
triggerEl.addEventListener('click', function (event) {
|
| 2699 |
+
event.preventDefault();
|
| 2700 |
+
// Переключаем таб, если он не активен
|
| 2701 |
+
if (!triggerEl.classList.contains('active')) {
|
| 2702 |
+
tabTrigger.show();
|
| 2703 |
+
}
|
| 2704 |
+
});
|
| 2705 |
+
});
|
| 2706 |
|
| 2707 |
// Активируем первую вкладку при загрузке, если ни одна не активна
|
| 2708 |
+
// (Bootstrap 5 обычно делает это сам, но можно оставить для надежности)
|
| 2709 |
+
const firstTabEl = document.querySelector('#adminTabs button[data-bs-toggle="tab"]');
|
| 2710 |
+
const activeTabEl = document.querySelector('#adminTabs button[data-bs-toggle="tab"].active');
|
| 2711 |
+
if (firstTabEl && !activeTabEl) {
|
| 2712 |
+
var firstTab = bootstrap.Tab.getInstance(firstTabEl) || new bootstrap.Tab(firstTabEl);
|
| 2713 |
+
firstTab.show();
|
| 2714 |
+
}
|
| 2715 |
+
|
| 2716 |
});
|
| 2717 |
|
| 2718 |
</script>
|
|
|
|
| 2740 |
logging.info("Запуск Flask приложения на порту 7860...")
|
| 2741 |
# debug=False для продакшена
|
| 2742 |
# host='0.0.0.0' для доступа из сети
|
| 2743 |
+
app.run(debug=True, host='0.0.0.0', port=7860)
|