Подправил реестр сменных заданий, права опрератора, добавил поле для хранения сколько сделано (для оператора) подправил ui в окне карточки сменного задания
All checks were successful
Deploy MES Core / deploy (push) Successful in 12s

This commit is contained in:
2026-04-16 08:28:15 +03:00
parent 2603c8f51c
commit 27d6f75dbe
5 changed files with 87 additions and 32 deletions

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-04-16 05:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0033_cuttingsession_is_synced_1c_and_more'),
]
operations = [
migrations.AddField(
model_name='workitem',
name='quantity_reported',
field=models.PositiveIntegerField(default=0, verbose_name='Прогресс (оператор), шт'),
),
migrations.AlterField(
model_name='machine',
name='machine_type',
field=models.CharField(choices=[('linear', 'Линейный'), ('sheet', 'Листовой'), ('post', 'Пост')], default='linear', max_length=10, verbose_name='Тип станка'),
),
]

View File

@@ -321,6 +321,7 @@ class WorkItem(models.Model):
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех') workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
quantity_plan = models.PositiveIntegerField('В план, шт', default=0) quantity_plan = models.PositiveIntegerField('В план, шт', default=0)
quantity_reported = models.PositiveIntegerField('Прогресс (оператор), шт', default=0)
quantity_done = models.PositiveIntegerField('Сделано, шт', default=0) quantity_done = models.PositiveIntegerField('Сделано, шт', default=0)
STATUS_CHOICES = [ STATUS_CHOICES = [

View File

@@ -11,7 +11,7 @@
<th>Материал</th> <th>Материал</th>
<th data-sort="false" class="text-center">Файлы</th> <th data-sort="false" class="text-center">Файлы</th>
<th data-sort="false" style="width: 160px;">Прогресс</th> <th data-sort="false" style="width: 160px;">Прогресс</th>
<th data-sort-type="number">План / Факт</th> <th data-sort-type="number">План / Прогресс</th>
<th>Статус</th> <th>Статус</th>
</tr> </tr>
</thead> </thead>
@@ -64,7 +64,7 @@
</td> </td>
<td> <td>
<span class="text-info fw-bold">{{ wi.quantity_plan }}</span> / <span class="text-info fw-bold">{{ wi.quantity_plan }}</span> /
<span class="text-success">{{ wi.quantity_done }}</span> <span class="text-success">{{ wi.quantity_reported|default:0 }}</span>
</td> </td>
<td> <td>
{% if wi.status == 'done' %} {% if wi.status == 'done' %}
@@ -93,5 +93,11 @@ document.addEventListener("DOMContentLoaded", function() {
if (href) window.location.href = href; if (href) window.location.href = 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> </script>

View File

@@ -41,7 +41,7 @@
<form method="post" action="{% url 'workitem_update' %}" class="mb-4" id="workitemForm"> <form method="post" action="{% url 'workitem_update' %}" class="mb-4" id="workitemForm">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="workitem_id" value="{{ workitem.id }}"> <input type="hidden" name="workitem_id" value="{{ workitem.id }}">
<input type="hidden" name="next" value="{{ request.get_full_path }}"> <input type="hidden" name="next" value="{{ back_url }}">
<div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body"> <div class="row g-3 mb-4 border-bottom border-secondary pb-3 text-body">
<div class="col-md-4"> <div class="col-md-4">
@@ -93,11 +93,11 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<small class="text-muted d-block">Факт</small> <small class="text-muted d-block">Прогресс</small>
{% if user_role in 'admin,technologist,master,operator' %} {% if user_role in 'admin,technologist,master,operator,prod_head' %}
<input type="number" min="0" class="form-control border-secondary" name="quantity_done" id="wiDone" value="{{ workitem.quantity_done }}"> <input type="number" min="0" class="form-control border-secondary" name="quantity_reported" id="wiReported" value="{{ workitem.quantity_reported|default:0 }}">
{% else %} {% else %}
<strong class="text-success fs-5">{{ workitem.quantity_done }} шт.</strong> <strong class="text-success fs-5">{{ workitem.quantity_reported|default:0 }} шт.</strong>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -279,7 +279,7 @@
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a> <a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
{% if user_role in 'admin,technologist,master,operator' %} {% if user_role in 'admin,technologist,master,operator' %}
<button type="button" class="btn btn-outline-accent px-4 fw-bold" onclick="document.getElementById('workitemForm')?.requestSubmit()"> <button type="button" class="btn btn-outline-accent px-4 fw-bold" onclick="const f=document.getElementById('workitemForm'); if(!f) return; if(f.requestSubmit){f.requestSubmit();} else {f.submit();}">
<i class="bi bi-save me-2"></i>Сохранить <i class="bi bi-save me-2"></i>Сохранить
</button> </button>
{% endif %} {% endif %}
@@ -289,20 +289,44 @@
</div> </div>
</div> </div>
{{ back_url|json_script:"wiBackUrl" }}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const done = document.getElementById('wiDone');
const form = document.getElementById('workitemForm'); const form = document.getElementById('workitemForm');
if (done) {
done.focus({ preventScroll: true }); function getBackUrl() {
done.select(); const el = document.getElementById('wiBackUrl');
done.addEventListener('keydown', function (e) { return el ? JSON.parse(el.textContent || '""') : '';
if (e.key === 'Enter') {
e.preventDefault();
if (form) form.requestSubmit();
}
});
} }
const reported = document.getElementById('wiReported');
if (reported) {
reported.focus({ preventScroll: true });
reported.select();
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
const url = getBackUrl();
if (url) window.location.href = url;
return;
}
if (e.key !== 'Enter') return;
const t = e.target;
const tag = (t && t.tagName) ? t.tagName.toLowerCase() : '';
if (tag === 'textarea') return;
e.preventDefault();
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}, true);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -562,9 +562,9 @@ class RegistryView(LoginRequiredMixin, ListView):
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000]) workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
for wi in workitems: for wi in workitems:
plan = int(wi.quantity_plan or 0) plan = int(wi.quantity_plan or 0)
done = int(wi.quantity_done or 0) reported = int(getattr(wi, 'quantity_reported', 0) or 0)
if plan > 0: if plan > 0:
pct = int(round(done * 100 / plan)) pct = int(round(reported * 100 / plan))
else: else:
pct = 0 pct = 0
wi.fact_pct = pct wi.fact_pct = pct
@@ -770,7 +770,7 @@ class PaintingPlanAddView(LoginRequiredMixin, View):
class WorkItemUpdateView(LoginRequiredMixin, View): class WorkItemUpdateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
roles = get_user_roles(request.user) roles = get_user_group_roles(request.user)
role = primary_role(roles) role = primary_role(roles)
is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False
@@ -828,7 +828,8 @@ class WorkItemUpdateView(LoginRequiredMixin, View):
return redirect(next_url) return redirect(next_url)
qty_plan = parse_int(request.POST.get('quantity_plan')) qty_plan = parse_int(request.POST.get('quantity_plan'))
qty_done = parse_int(request.POST.get('quantity_done')) qty_reported = parse_int(request.POST.get('quantity_reported'))
qty_done = parse_int(request.POST.get('quantity_done')) if role in ['admin', 'technologist'] else None
workshop_id = parse_int(request.POST.get('workshop_id')) workshop_id = parse_int(request.POST.get('workshop_id'))
machine_id = parse_int(request.POST.get('machine_id')) machine_id = parse_int(request.POST.get('machine_id'))
date_raw = (request.POST.get('date') or '').strip() date_raw = (request.POST.get('date') or '').strip()
@@ -871,15 +872,14 @@ class WorkItemUpdateView(LoginRequiredMixin, View):
wi.quantity_plan = qty_plan wi.quantity_plan = qty_plan
changed_fields.append('quantity_plan') changed_fields.append('quantity_plan')
if qty_done is not None and qty_done >= 0: if qty_reported is not None and qty_reported >= 0:
# Комментарий: факт не должен превышать план по строке, иначе ломается «доступно к покраске».
plan_val = int((qty_plan if qty_plan is not None else wi.quantity_plan) or 0) plan_val = int((qty_plan if qty_plan is not None else wi.quantity_plan) or 0)
if plan_val > 0 and qty_done > plan_val: if plan_val > 0 and qty_reported > plan_val:
messages.error(request, f'Факт ({qty_done}) не может быть больше плана ({plan_val}).') messages.error(request, f'Прогресс ({qty_reported}) не может быть больше плана ({plan_val}).')
return redirect(next_url) return redirect(next_url)
wi.quantity_done = qty_done wi.quantity_reported = qty_reported
changed_fields.append('quantity_done') changed_fields.append('quantity_reported')
if machine_id is not None and role in ['admin', 'technologist', 'master']: if machine_id is not None and role in ['admin', 'technologist', 'master']:
wi.machine_id = machine_id wi.machine_id = machine_id
@@ -1399,17 +1399,18 @@ class WorkItemDetailView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_detail.html' template_name = 'shiftflow/workitem_detail.html'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
roles = get_user_roles(request.user) roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']): if not has_any_role(roles, ['admin', 'technologist', 'master', 'clerk', 'operator', 'observer', 'prod_head', 'director']):
return redirect('registry') return redirect('registry')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None) roles = get_user_group_roles(self.request.user)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') role = primary_role(roles)
ctx['user_role'] = role ctx['user_role'] = role
ctx['can_edit_entity'] = role in ['admin', 'technologist'] ctx['user_roles'] = sorted(roles)
ctx['can_edit_entity'] = has_any_role(roles, ['admin', 'technologist'])
wi = get_object_or_404( wi = get_object_or_404(
WorkItem.objects.select_related( WorkItem.objects.select_related(