Открыл мастеру возможность просмотра сделок и потребность в деталях, подправил окно редактирования позиции сделки, подправил работу фильтра
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s

This commit is contained in:
2026-04-02 22:05:41 +03:00
parent 7cb00792ca
commit 9554d47301
11 changed files with 306 additions and 262 deletions

View File

@@ -25,9 +25,11 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if user_role in 'admin,technologist' %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal"> <button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal">
<i class="bi bi-plus-lg me-1"></i>Создать сделку <i class="bi bi-plus-lg me-1"></i>Создать сделку
</button> </button>
{% endif %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'customers' %}"> <a class="btn btn-outline-secondary btn-sm" href="{% url 'customers' %}">
<i class="bi bi-arrow-left me-1"></i>К заказчикам <i class="bi bi-arrow-left me-1"></i>К заказчикам
</a> </a>

View File

@@ -4,9 +4,11 @@
<div class="card shadow border-secondary"> <div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center"> <div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-building me-2"></i>Заказчики</h3> <h3 class="text-accent mb-0"><i class="bi bi-building me-2"></i>Заказчики</h3>
{% if user_role in 'admin,technologist' %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#companyModal"> <button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#companyModal">
<i class="bi bi-plus-lg me-1"></i>Добавить заказчика <i class="bi bi-plus-lg me-1"></i>Добавить заказчика
</button> </button>
{% endif %}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">

View File

@@ -7,12 +7,27 @@
<div class="card shadow-sm border-secondary mb-4"> <div class="card shadow-sm border-secondary mb-4">
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-3"> <div class="card-header border-secondary d-flex justify-content-between align-items-center py-3">
<h3 class="text-accent mb-0"><i class="bi bi-info-circle me-2"></i>{{ item.task.drawing_name|default:"Без названия" }}</h3> <h3 class="text-accent mb-0">
<span class="badge bg-secondary">Сделка № {{ item.task.deal.number }}</span> {% if user_role == 'operator' %}
<i class="bi bi-info-circle me-2"></i>{{ item.task.drawing_name|default:"Без названия" }}
{% else %}
<a href="{% url 'task_items' item.task.id %}" class="text-decoration-none text-reset">
<i class="bi bi-info-circle me-2"></i>{{ item.task.drawing_name|default:"Без названия" }}
</a>
{% endif %}
</h3>
{% if user_role == 'operator' %}
<span class="badge bg-secondary">Сделка № {{ item.task.deal.number }}</span>
{% else %}
<a href="{% url 'planning_deal' item.task.deal.id %}" class="text-decoration-none">
<span class="badge bg-secondary">Сделка № {{ item.task.deal.number }}</span>
</a>
{% endif %}
</div> </div>
<form method="post" id="mainForm" class="card-body p-4"> <form method="post" id="mainForm" class="card-body p-4">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ back_url }}">
{% if errors %} {% if errors %}
<div class="alert alert-danger mb-4"> <div class="alert alert-danger mb-4">
@@ -55,22 +70,12 @@
{% if user_role in 'operator,master' %} {% if user_role in 'operator,master' %}
{% if item.status == 'work' %} {% if item.status == 'work' %}
<div class="bg-body-tertiary p-3 rounded border mb-4 text-center"> <div class="bg-body-tertiary p-3 rounded border mb-4 text-center">
<h5 class="mb-3">Закрыть задание:</h5> <div class="row g-3 text-start">
<div class="btn-group btn-group-lg w-100"> <div class="col-md-4">
<button type="button" class="btn btn-success" onclick="closeTask('done')"> <label class="small text-muted">Факт (шт)</label>
<i class="bi bi-check-all"></i> Выполнено <input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}" max="{{ item.quantity_plan }}">
</button> </div>
<button type="button" class="btn btn-outline-warning" onclick="showPartial()">
Частично
</button>
</div>
<div id="partialInput" class="mt-3 d-none">
<label class="small text-muted">Сколько сделано?</label>
<input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control form-control-lg text-center mx-auto" style="max-width: 200px;" value="{{ item.quantity_fact }}" max="{{ item.quantity_plan }}">
</div>
<div class="row g-3 mt-3 text-start">
<div class="col-md-4"> <div class="col-md-4">
<label class="small text-muted">Взятый материал</label> <label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м" required> <input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м" required>
@@ -187,38 +192,25 @@
{% endif %} {% endif %}
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<a href="{% url 'registry' %}" class="btn btn-outline-secondary">Назад</a> <a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
<button type="submit" class="btn btn-outline-accent px-5 fw-bold"> <div class="d-flex gap-2">
<i class="bi bi-save me-2"></i> <input type="hidden" name="action" id="actionField" value="save">
{% if user_role in 'operator,master' %}Закрыть задание{% else %}Сохранить{% endif %} {% if item.status == 'work' %}
</button> <button type="submit" class="btn btn-success px-4" onclick="document.getElementById('actionField').value='close_done'">
<i class="bi bi-check-all me-2"></i>Выполнено
</button>
<button type="submit" class="btn btn-outline-warning px-4" onclick="document.getElementById('actionField').value='close_partial'">
Частично
</button>
{% endif %}
<button type="submit" class="btn btn-outline-accent px-4 fw-bold" onclick="document.getElementById('actionField').value='save'">
<i class="bi bi-save me-2"></i>Сохранить
</button>
</div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<script>
function closeTask(status) {
document.getElementById('id_status').value = status;
// Если "Выполнено", автоматом ставим факт = плану
if(status === 'done') {
const factInput = document.getElementById('id_quantity_fact');
if(factInput) factInput.value = "{{ item.quantity_plan }}";
else {
let hiddenFact = document.createElement('input');
hiddenFact.type = 'hidden';
hiddenFact.name = 'quantity_fact';
hiddenFact.value = "{{ item.quantity_plan }}";
document.getElementById('mainForm').appendChild(hiddenFact);
}
}
document.getElementById('mainForm').submit();
}
function showPartial() {
document.getElementById('partialInput').classList.remove('d-none');
document.getElementById('id_status').value = 'partial';
}
</script>
{% endblock %} {% endblock %}

View File

@@ -66,7 +66,7 @@
</div> </div>
<div class="col-md-1 text-end mt-auto"> <div class="col-md-1 text-end mt-auto">
<a href="{% url 'registry' %}" class="btn btn-outline-secondary btn-sm w-100" title="Сброс"> <a href="{% url 'registry' %}?reset=1" class="btn btn-outline-secondary btn-sm w-100" id="registryResetBtn" title="Сброс">
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс <i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
</a> </a>
</div> </div>
@@ -104,6 +104,10 @@
function restoreFilters(){ function restoreFilters(){
if (!form) return false; if (!form) return false;
const qs = new URLSearchParams(window.location.search); const qs = new URLSearchParams(window.location.search);
if (qs.get('reset') === '1') {
try { localStorage.removeItem('registry_filters'); } catch(_) {}
return false;
}
if (qs.get('filtered') === '1') return false; if (qs.get('filtered') === '1') return false;
let raw = null; try { raw = localStorage.getItem('registry_filters'); } catch(_){} let raw = null; try { raw = localStorage.getItem('registry_filters'); } catch(_){}
if (!raw) return false; if (!raw) return false;
@@ -128,6 +132,14 @@
if (form){ if (form){
form.addEventListener('change', saveFilters, true); form.addEventListener('change', saveFilters, true);
const resetBtn = document.getElementById('registryResetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
try { localStorage.removeItem('registry_filters'); } catch(_) {}
});
}
restoreFilters(); restoreFilters();
} }
}); });

View File

@@ -0,0 +1,94 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th>Сделка</th>
<th>Станок</th>
<th>Наименование</th>
<th>Габариты</th>
<th style="width: 160px;">Прогресс</th>
<th>План / Факт</th>
<th>Материал</th>
<th class="text-center">Файлы</th>
<th class="text-center">1С</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="clickable-row" data-href="{% url 'item_detail' item.pk %}">
<td class="small">{{ item.date|date:"d.m.y" }}</td>
<td><span class="text-accent fw-bold">{{ item.task.deal.number|default:"-" }}</span></td>
<td><span class="badge bg-dark border border-secondary">{{ item.machine.name }}</span></td>
<td class="fw-bold">{{ item.task.drawing_name|default:"Б/ч" }}</td>
<td class="small">{{ item.task.size_value|default:"-" }}</td>
<td>
<div class="progress bg-secondary-subtle border border-secondary sf-item-progress"
style="height: 10px;"
data-fact-width="{{ item.fact_width|default:0 }}"
title="Факт: {{ item.fact_pct|default:0 }}%">
<div class="progress-bar {{ item.fact_bar_class|default:'bg-warning' }} sf-item-progress-bar"></div>
</div>
</td>
<td>
<span class="text-info fw-bold">{{ item.quantity_plan }}</span> /
<span class="text-success">{{ item.quantity_fact }}</span>
</td>
<td class="small text-muted">{{ item.task.material.full_name|default:item.task.material.name|default:"-" }}</td>
<td class="text-center">
{% if item.task.drawing_file %}
<a href="{{ item.task.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
{% if item.task.extra_drawing %}
<a href="{{ item.task.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
</td>
<td class="text-center">
{% if item.is_synced_1c %}
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
{% else %}
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
{% endif %}
</td>
<td>
<span class="badge {% if item.status == 'work' %}bg-primary{% elif item.status == 'done' %}bg-success{% elif item.status == 'partial' %}bg-success-subtle text-success-emphasis border border-success-subtle{% else %}bg-secondary{% endif %}">
{{ item.get_status_display }}
</span>
</td>
</tr>
{% empty %}
<tr><td colspan="11" class="text-center p-5 text-muted">Заданий не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll(".clickable-row").forEach(row => {
row.addEventListener("click", function(e) {
if (e.target.closest('.stop-prop')) return;
window.location.href = this.dataset.href;
});
});
document.querySelectorAll('.sf-item-progress').forEach(function (el) {
const w = parseInt(el.getAttribute('data-fact-width') || '0', 10) || 0;
const bar = el.querySelector('.sf-item-progress-bar');
if (bar) bar.style.width = `${w}%`;
});
});
</script>

View File

@@ -19,9 +19,11 @@
</div> </div>
</form> </form>
</div> </div>
{% if user_role in 'admin,technologist' %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal" data-mode="create"> <button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal" data-mode="create">
<i class="bi bi-plus-lg me-1"></i>Добавить сделку <i class="bi bi-plus-lg me-1"></i>Добавить сделку
</button> </button>
{% endif %}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">

View File

@@ -20,9 +20,11 @@
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}"> <a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}">
<i class="bi bi-arrow-left me-1"></i>Назад <i class="bi bi-arrow-left me-1"></i>Назад
</a> </a>
{% if user_role in 'admin,technologist' %}
<a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}&next={% url 'planning_deal' deal.id %}"> <a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}&next={% url 'planning_deal' deal.id %}">
<i class="bi bi-plus-lg me-1"></i>Добавить деталь <i class="bi bi-plus-lg me-1"></i>Добавить деталь
</a> </a>
{% endif %}
</div> </div>
</div> </div>
@@ -72,6 +74,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
{% if user_role in 'admin,technologist' %}
<button <button
type="button" type="button"
class="btn btn-outline-accent btn-sm" class="btn btn-outline-accent btn-sm"
@@ -83,6 +86,7 @@
> >
<i class="bi bi-plus-lg me-1"></i>В план <i class="bi bi-plus-lg me-1"></i>В план
</button> </button>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@@ -13,81 +13,6 @@
{% endif %} {% endif %}
</div> </div>
<div class="card-body p-0"> {% include 'shiftflow/partials/_items_table.html' with items=items %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th>Сделка</th>
<th>Станок</th>
<th>Наименование</th>
<th>Габариты</th>
<th>План / Факт</th>
<th>Материал</th>
<th class="text-center">Файлы</th>
<th class="text-center">1С</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="clickable-row" data-href="{% url 'item_detail' item.pk %}">
<td class="small">{{ item.date|date:"d.m.y" }}</td>
<td><span class="text-accent fw-bold">{{ item.task.deal.number|default:"-" }}</span></td>
<td><span class="badge bg-dark border border-secondary">{{ item.machine.name }}</span></td>
<td class="fw-bold">{{ item.task.drawing_name|default:"Б/ч" }}</td>
<td class="small">{{ item.task.size_value|default:"-" }}</td>
<td>
<span class="text-info fw-bold">{{ item.quantity_plan }}</span> /
<span class="text-success">{{ item.quantity_fact }}</span>
</td>
<td class="small text-muted">{{ item.task.material.full_name|default:item.task.material.name|default:"-" }}</td>
<td class="text-center">
{% if item.task.drawing_file %}
<a href="{{ item.task.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
{% if item.task.extra_drawing %}
<a href="{{ item.task.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
</td>
<td class="text-center">
{% if item.is_synced_1c %}
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
{% else %}
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
{% endif %}
</td>
<td>
<span class="badge {% if item.status == 'work' %}bg-primary{% elif item.status == 'done' %}bg-success{% elif item.status == 'partial' %}bg-success-subtle text-success-emphasis border border-success-subtle{% else %}bg-secondary{% endif %}">
{{ item.get_status_display }}
</span>
</td>
</tr>
{% empty %}
<tr><td colspan="10" class="text-center p-5 text-muted">Заданий не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const rows = document.querySelectorAll(".clickable-row");
rows.forEach(row => {
row.addEventListener("click", function(e) {
// Если нажали на ссылку файла (класс stop-prop), не переходим на страницу деталей
if (e.target.closest('.stop-prop')) return;
// Иначе переходим по ссылке из data-href
window.location.href = this.dataset.href;
});
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -16,77 +16,6 @@
</a> </a>
</div> </div>
<div class="card-body p-0"> {% include 'shiftflow/partials/_items_table.html' with items=items %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th>Сделка</th>
<th>Станок</th>
<th>Наименование</th>
<th>Габариты</th>
<th>План / Факт</th>
<th>Материал</th>
<th class="text-center">Файлы</th>
<th class="text-center">1С</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr class="clickable-row" data-href="{% url 'item_detail' it.pk %}">
<td class="small">{{ it.date|date:"d.m.y" }}</td>
<td><span class="text-accent fw-bold">{{ it.task.deal.number|default:"-" }}</span></td>
<td><span class="badge bg-dark border border-secondary">{{ it.machine.name }}</span></td>
<td class="fw-bold">{{ it.task.drawing_name|default:"Б/ч" }}</td>
<td class="small">{{ it.task.size_value|default:"-" }}</td>
<td>
<span class="text-info fw-bold">{{ it.quantity_plan }}</span> /
<span class="text-success">{{ it.quantity_fact }}</span>
</td>
<td class="small text-muted">{{ it.task.material.full_name|default:it.task.material.name|default:"-" }}</td>
<td class="text-center">
{% if it.task.drawing_file %}
<a href="{{ it.task.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
{% if it.task.extra_drawing %}
<a href="{{ it.task.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
</td>
<td class="text-center">
{% if it.is_synced_1c %}
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
{% else %}
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
{% endif %}
</td>
<td>
<span class="badge {% if it.status == 'work' %}bg-primary{% elif it.status == 'done' %}bg-success{% elif it.status == 'partial' %}bg-success-subtle text-success-emphasis border border-success-subtle{% else %}bg-secondary{% endif %}">{{ it.get_status_display }}</span>
</td>
</tr>
{% empty %}
<tr><td colspan="10" class="text-center p-5 text-muted">Пунктов сменки не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const rows = document.querySelectorAll(".clickable-row");
rows.forEach(row => {
row.addEventListener("click", function(e) {
if (e.target.closest('.stop-prop')) return;
window.location.href = this.dataset.href;
});
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from urllib.parse import urlsplit
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@@ -37,7 +38,10 @@ class RegistryView(LoginRequiredMixin, ListView):
user = self.request.user user = self.request.user
profile = getattr(user, 'profile', None) profile = getattr(user, 'profile', None)
role = profile.role if profile else 'operator' role = profile.role if profile else 'operator'
# Флаг, что фильтрация была применена через форму. Если нет — используем дефолты
filtered = self.request.GET.get('filtered') filtered = self.request.GET.get('filtered')
# Принудительный сброс фильтров (?reset=1) — ведёт себя как первый заход на страницу
reset = self.request.GET.get('reset')
# Станки # Станки
m_ids = self.request.GET.getlist('m_ids') m_ids = self.request.GET.getlist('m_ids')
@@ -59,18 +63,18 @@ class RegistryView(LoginRequiredMixin, ListView):
expanded.append(s) expanded.append(s)
queryset = queryset.filter(status__in=expanded) queryset = queryset.filter(status__in=expanded)
# Даты # Диапазон дат, задаваемый пользователем. Если фильтры не активны или явно указан reset=1 — используем дефолты
start_date = self.request.GET.get('start_date') start_date = self.request.GET.get('start_date')
end_date = self.request.GET.get('end_date') end_date = self.request.GET.get('end_date')
if not filtered: # Дефолтный режим: последние 7 дней и только статус "В работе"
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate() today = timezone.localdate()
if role == 'clerk': week_ago = today - timezone.timedelta(days=7)
queryset = queryset.filter(date=today, status__in=['done', 'partial']) queryset = queryset.filter(date__gte=week_ago, date__lte=today, status__in=['work'])
elif role in ['operator', 'master']:
queryset = queryset.filter(date=today, status__in=['work'])
else:
queryset = queryset.filter(date=today, status__in=['work', 'leftover'])
else: else:
# Пользователь указал фильтры вручную — применяем их как есть
if start_date: if start_date:
queryset = queryset.filter(date__gte=start_date) queryset = queryset.filter(date__gte=start_date)
if end_date: if end_date:
@@ -103,16 +107,15 @@ class RegistryView(LoginRequiredMixin, ListView):
context['machines'] = machines context['machines'] = machines
filtered = self.request.GET.get('filtered') filtered = self.request.GET.get('filtered')
if not filtered: reset = self.request.GET.get('reset')
today_str = timezone.localdate().strftime('%Y-%m-%d') # Дефолтное состояние формы фильтра: все станки включены, статус "В работе",
context['start_date'] = today_str # период от сегодня7 до сегодня. Совпадает с серверной выборкой выше
context['end_date'] = today_str if (not filtered) or reset:
if role == 'clerk': today = timezone.localdate()
context['selected_statuses'] = ['closed'] week_ago = today - timezone.timedelta(days=7)
elif role in ['operator', 'master']: context['start_date'] = week_ago.strftime('%Y-%m-%d')
context['selected_statuses'] = ['work'] context['end_date'] = today.strftime('%Y-%m-%d')
else: context['selected_statuses'] = ['work']
context['selected_statuses'] = ['work', 'leftover']
context['selected_machines'] = [m.id for m in machines] context['selected_machines'] = [m.id for m in machines]
context['all_selected_machines'] = True context['all_selected_machines'] = True
else: else:
@@ -123,6 +126,19 @@ class RegistryView(LoginRequiredMixin, ListView):
context['is_synced'] = self.request.GET.get('is_synced', '') context['is_synced'] = self.request.GET.get('is_synced', '')
context['all_selected_machines'] = False context['all_selected_machines'] = False
items = list(context.get('items') or [])
for it in items:
plan = int(it.quantity_plan or 0)
fact = int(it.quantity_fact or 0)
if plan > 0:
fact_pct = int(round(fact * 100 / plan))
else:
fact_pct = 0
it.fact_pct = fact_pct
it.fact_width = max(0, min(100, fact_pct))
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
context['items'] = items
return context return context
@@ -222,7 +238,7 @@ class PlanningView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']: if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -249,7 +265,7 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']: if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -282,6 +298,8 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
).order_by('-id') ).order_by('-id')
tasks = list(tasks_qs) tasks = list(tasks_qs)
# Рассчитываем показатели прогресса для визуализации:
# done_pct/plan_pct — проценты от "Надо"; done_width/plan_width — ширины сегментов бары, ограниченные 0..100
for t in tasks: for t in tasks:
need = int(t.quantity_ordered or 0) need = int(t.quantity_ordered or 0)
done_qty = int(t.done_qty or 0) done_qty = int(t.done_qty or 0)
@@ -313,7 +331,7 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']: if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -329,7 +347,17 @@ class TaskItemsView(LoginRequiredMixin, TemplateView):
) )
context['task'] = task context['task'] = task
items = Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id') items = list(Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id'))
for it in items:
plan = int(it.quantity_plan or 0)
fact = int(it.quantity_fact or 0)
if plan > 0:
fact_pct = int(round(fact * 100 / plan))
else:
fact_pct = 0
it.fact_pct = fact_pct
it.fact_width = max(0, min(100, fact_pct))
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
context['items'] = items context['items'] = items
return context return context
@@ -340,7 +368,7 @@ class CustomersView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']: if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -361,7 +389,7 @@ class CustomerDealsView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist']: if role not in ['admin', 'technologist', 'master', 'clerk']:
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -667,6 +695,22 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
context['user_role'] = role context['user_role'] = role
context['machines'] = Machine.objects.all() context['machines'] = Machine.objects.all()
# Вычисляем URL "Назад": приоритетно берём ?next=..., иначе пробуем Referer
# Используем только ссылки на текущий хост, чтобы избежать внешних редиректов
next_url = (self.request.GET.get('next') or '').strip()
back_url = ''
if next_url.startswith('/'):
back_url = next_url
else:
ref = (self.request.META.get('HTTP_REFERER') or '').strip()
if ref:
parts = urlsplit(ref)
if parts.netloc == self.request.get_host():
back_url = parts.path + (('?' + parts.query) if parts.query else '')
if not back_url:
back_url = str(reverse_lazy('registry'))
context['back_url'] = back_url
return context return context
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -674,7 +718,24 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
# Поддерживаем "умный" возврат после действия: ?next=... или Referer
next_url = (request.POST.get('next') or '').strip()
if not next_url:
ref = (request.META.get('HTTP_REFERER') or '').strip()
if ref:
parts = urlsplit(ref)
if parts.netloc == request.get_host():
next_url = parts.path + (('?' + parts.query) if parts.query else '')
def redirect_back():
# Возвращаемся туда, откуда пришли, иначе в реестр
if next_url.startswith('/'):
return redirect(next_url)
return redirect('registry')
if role in ['admin', 'technologist']: if role in ['admin', 'technologist']:
action = request.POST.get('action', 'save')
machine_id = request.POST.get('machine') machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit(): if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id) self.object.machine_id = int(machine_id)
@@ -691,11 +752,6 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
if quantity_fact and quantity_fact.isdigit(): if quantity_fact and quantity_fact.isdigit():
self.object.quantity_fact = int(quantity_fact) self.object.quantity_fact = int(quantity_fact)
status = request.POST.get('status')
allowed_statuses = {k for k, _ in self.object.STATUS_CHOICES}
if status in allowed_statuses:
self.object.status = status
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
self.object.material_taken = request.POST.get('material_taken', self.object.material_taken) self.object.material_taken = request.POST.get('material_taken', self.object.material_taken)
self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste) self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste)
@@ -707,35 +763,70 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
except ValueError: except ValueError:
pass pass
# Действия закрытия для админа/технолога
if action == 'close_done' and self.object.status == 'work':
self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done'
self.object.save()
return redirect_back()
if action == 'close_partial' and self.object.status == 'work':
try:
fact = int(request.POST.get('quantity_fact', '0'))
except ValueError:
fact = 0
fact = max(0, min(fact, self.object.quantity_plan))
residual = self.object.quantity_plan - fact
self.object.quantity_fact = fact
self.object.status = 'partial'
self.object.save()
if residual > 0:
Item.objects.create(
task=self.object.task,
date=self.object.date,
machine=self.object.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)
return redirect_back()
self.object.save() self.object.save()
return redirect('registry') return redirect_back()
if role in ['operator', 'master']: if role in ['operator', 'master']:
action = request.POST.get('action', 'save')
material_taken = (request.POST.get('material_taken') or '').strip() material_taken = (request.POST.get('material_taken') or '').strip()
usable_waste = (request.POST.get('usable_waste') or '').strip() usable_waste = (request.POST.get('usable_waste') or '').strip()
scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip() scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip()
status = request.POST.get('status', self.object.status)
machine_changed = False if action == 'save':
if role == 'master': qf = request.POST.get('quantity_fact')
machine_id = request.POST.get('machine') if qf and qf.isdigit():
if machine_id and machine_id.isdigit(): self.object.quantity_fact = int(qf)
self.object.machine_id = int(machine_id) machine_changed = False
machine_changed = True if role == 'master':
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)
machine_changed = True
fields = ['quantity_fact']
if machine_changed:
fields.append('machine')
self.object.save(update_fields=fields)
return redirect_back()
# Разрешаем мастеру редактировать операторские поля всегда, if self.object.status != 'work':
# оператору — только в процессе закрытия return redirect_back()
if role == 'operator' and self.object.status != 'work':
return redirect('registry')
errors = [] errors = []
if role == 'operator' and self.object.status == 'work': if not material_taken:
if not material_taken: errors.append('Заполни поле "Взятый материал"')
errors.append('Заполни поле "Взятый материал"') if not usable_waste:
if not usable_waste: errors.append('Заполни поле "Остаток ДО"')
errors.append('Заполни поле "Остаток ДО"') if scrap_weight_raw == '':
if scrap_weight_raw == '': errors.append('Заполни поле "Лом (кг)" (можно 0)')
errors.append('Заполни поле "Лом (кг)" (можно 0)')
scrap_weight = None scrap_weight = None
if scrap_weight_raw != '': if scrap_weight_raw != '':
@@ -749,21 +840,18 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
context['errors'] = errors context['errors'] = errors
return self.render_to_response(context) return self.render_to_response(context)
if material_taken: self.object.material_taken = material_taken
self.object.material_taken = material_taken self.object.usable_waste = usable_waste
if usable_waste:
self.object.usable_waste = usable_waste
if scrap_weight is not None: if scrap_weight is not None:
self.object.scrap_weight = scrap_weight self.object.scrap_weight = scrap_weight
# Логика закрытия доступна и мастеру, и оператору, но только из 'work' if action == 'close_done':
if self.object.status == 'work' and status == 'done':
self.object.quantity_fact = self.object.quantity_plan self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done' self.object.status = 'done'
self.object.save() self.object.save()
return redirect('registry') return redirect_back()
if self.object.status == 'work' and status == 'partial': if action == 'close_partial':
try: try:
fact = int(request.POST.get('quantity_fact', '0')) fact = int(request.POST.get('quantity_fact', '0'))
except ValueError: except ValueError:
@@ -789,24 +877,18 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
status='leftover', status='leftover',
is_synced_1c=False, is_synced_1c=False,
) )
return redirect_back()
return redirect('registry') return redirect_back()
# Если статус не менялся (или не 'work'), просто сохраняем поля
update_fields = ['material_taken', 'usable_waste', 'scrap_weight']
if machine_changed:
update_fields.append('machine')
self.object.save(update_fields=update_fields)
return redirect('registry')
if role == 'clerk': if role == 'clerk':
if self.object.status not in ['done', 'partial']: if self.object.status not in ['done', 'partial']:
return redirect('registry') return redirect_back()
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
self.object.save(update_fields=['is_synced_1c']) self.object.save(update_fields=['is_synced_1c'])
return redirect('registry') return redirect_back()
return redirect('registry') return redirect_back()
def get_success_url(self): def get_success_url(self):
return reverse_lazy('registry') return reverse_lazy('registry')

View File

@@ -15,7 +15,7 @@
<a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a> <a class="nav-link {% if request.resolver_match.url_name == 'registry' %}active{% endif %}" href="{% url 'registry' %}">Реестр</a>
</li> </li>
{% if user_role in 'admin,technologist' %} {% if user_role in 'admin,technologist,master,clerk' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a> <a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
</li> </li>