Добавил журнал ошибок

This commit is contained in:
2025-12-15 17:54:26 +03:00
parent 480bb60855
commit 1a953cc558
12 changed files with 1162 additions and 7 deletions

View File

@@ -1380,3 +1380,77 @@ class SourceRequestStatusHistoryAdmin(BaseAdmin):
def has_change_permission(self, request, obj=None):
return False
# ============================================================================
# Errors Report Admin
# ============================================================================
from .models import IssueType, DailyReport, DowntimePeriod, IssueMark
class DowntimePeriodInline(admin.TabularInline):
"""Inline для периодов простоя."""
model = DowntimePeriod
extra = 1
fields = ('start_time', 'end_time', 'reason')
class IssueMarkInline(admin.TabularInline):
"""Inline для отметок об ошибках."""
model = IssueMark
extra = 0
fields = ('issue_type', 'is_present')
autocomplete_fields = ('issue_type',)
@admin.register(IssueType)
class IssueTypeAdmin(BaseAdmin):
"""Админ-панель для типов ошибок/неисправностей."""
list_display = ('name', 'category')
list_filter = ('category',)
search_fields = ('name',)
ordering = ('category', 'name')
@admin.register(DailyReport)
class DailyReportAdmin(BaseAdmin):
"""Админ-панель для ежедневных отчётов."""
list_display = (
'date',
'daily_work_hours',
'weekly_work_hours',
'downtime_count',
'issues_count',
'created_at',
)
list_filter = (
('date', DateRangeQuickSelectListFilterBuilder()),
)
search_fields = ('explanation', 'comment')
ordering = ('-date',)
readonly_fields = ('created_at', 'updated_at', 'created_by')
inlines = [DowntimePeriodInline, IssueMarkInline]
fieldsets = (
('Основная информация', {
'fields': ('date', 'daily_work_hours', 'weekly_work_hours')
}),
('Примечания', {
'fields': ('explanation', 'comment')
}),
('Метаданные', {
'fields': ('created_at', 'updated_at', 'created_by'),
'classes': ('collapse',)
}),
)
def downtime_count(self, obj):
return obj.downtime_periods.count()
downtime_count.short_description = 'Простоев'
def issues_count(self, obj):
return obj.issue_marks.filter(is_present=True).count()
issues_count.short_description = 'Ошибок/неисправностей'

View File

@@ -0,0 +1,80 @@
# Generated by Django 5.2.7 on 2025-12-15 13:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0026_alter_userpermission_code'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='IssueType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Название')),
('category', models.CharField(choices=[('error', 'Ошибка'), ('malfunction', 'Неисправность')], default='error', max_length=20, verbose_name='Категория')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
],
options={
'verbose_name': 'Тип ошибки/неисправности',
'verbose_name_plural': 'Типы ошибок/неисправностей',
'ordering': ['category', 'order', 'name'],
},
),
migrations.CreateModel(
name='DailyReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(db_index=True, help_text='Дата отчёта', unique=True, verbose_name='Дата')),
('daily_work_hours', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Время работы за день (ч)')),
('weekly_work_hours', models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='Время работы за неделю (ч)')),
('explanation', models.TextField(blank=True, null=True, verbose_name='Пояснение')),
('comment', models.TextField(blank=True, null=True, verbose_name='Комментарий')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='daily_reports_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
],
options={
'verbose_name': 'Ежедневный отчёт',
'verbose_name_plural': 'Ежедневные отчёты',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='DowntimePeriod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.TimeField(verbose_name='Время начала')),
('end_time', models.TimeField(verbose_name='Время окончания')),
('reason', models.TextField(verbose_name='Причина простоя')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downtime_periods', to='mainapp.dailyreport', verbose_name='Отчёт')),
],
options={
'verbose_name': 'Период простоя',
'verbose_name_plural': 'Периоды простоя',
'ordering': ['start_time'],
},
),
migrations.CreateModel(
name='IssueMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_present', models.BooleanField(default=False, verbose_name='Наличие')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_marks', to='mainapp.dailyreport', verbose_name='Отчёт')),
('issue_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.issuetype', verbose_name='Тип ошибки/неисправности')),
],
options={
'verbose_name': 'Отметка об ошибке',
'verbose_name_plural': 'Отметки об ошибках',
'ordering': ['issue_type__category', 'issue_type__order'],
'unique_together': {('report', 'issue_type')},
},
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-12-15 13:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0027_errors_report_models'),
]
operations = [
migrations.AlterModelOptions(
name='issuemark',
options={'ordering': ['issue_type__category', 'issue_type__name'], 'verbose_name': 'Отметка об ошибке', 'verbose_name_plural': 'Отметки об ошибках'},
),
migrations.AlterModelOptions(
name='issuetype',
options={'ordering': ['category', 'name'], 'verbose_name': 'Тип ошибки/неисправности', 'verbose_name_plural': 'Типы ошибок/неисправностей'},
),
migrations.RemoveField(
model_name='issuetype',
name='is_active',
),
migrations.RemoveField(
model_name='issuetype',
name='order',
),
]

View File

@@ -26,6 +26,9 @@ from .tech_analyze import TechAnalyze, ObjectMark
# Заявки
from .requests import SourceRequest, SourceRequestStatusHistory
# Отчёты об ошибках
from .errors_report import IssueType, DailyReport, DowntimePeriod, IssueMark
# Вспомогательные функции для default значений
from .defaults import (
get_default_polarization,
@@ -62,6 +65,11 @@ __all__ = [
# Заявки
'SourceRequest',
'SourceRequestStatusHistory',
# Отчёты об ошибках
'IssueType',
'DailyReport',
'DowntimePeriod',
'IssueMark',
# Функции
'get_default_polarization',
'get_default_modulation',

View File

@@ -1,9 +1,130 @@
from django.db import models
from django.contrib.auth import get_user_model
# class IssueType(models.Model):
# name = models.CharField(max_length=100)
# CATEGORY_CHOICES = [
# ('error', 'Ошибка'),
# ('malfunction', 'Неисправность'),
# ]
# category = models.CharField(max_length=12, choices=CATEGORY_CHOICES)
User = get_user_model()
class IssueType(models.Model):
"""Тип ошибки или неисправности"""
CATEGORY_CHOICES = [
('error', 'Ошибка'),
('malfunction', 'Неисправность'),
]
name = models.CharField(max_length=255, verbose_name="Название")
category = models.CharField(
max_length=20,
choices=CATEGORY_CHOICES,
default='error',
verbose_name='Категория'
)
def __str__(self):
return f"{self.name} ({self.get_category_display()})"
class Meta:
verbose_name = "Тип ошибки/неисправности"
verbose_name_plural = "Типы ошибок/неисправностей"
ordering = ["category", "name"]
class DailyReport(models.Model):
"""Ежедневный отчёт"""
date = models.DateField(
unique=True,
verbose_name="Дата",
db_index=True,
help_text="Дата отчёта"
)
daily_work_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0,
verbose_name="Время работы за день (ч)"
)
weekly_work_hours = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name="Время работы за неделю (ч)"
)
explanation = models.TextField(blank=True, null=True, verbose_name='Пояснение')
comment = models.TextField(blank=True, null=True, verbose_name='Комментарий')
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создано")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлено")
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='daily_reports_created',
verbose_name="Создал"
)
def __str__(self):
return f"Отчёт за {self.date}"
class Meta:
verbose_name = "Ежедневный отчёт"
verbose_name_plural = "Ежедневные отчёты"
ordering = ["-date"]
class DowntimePeriod(models.Model):
"""Период простоя"""
report = models.ForeignKey(
DailyReport,
on_delete=models.CASCADE,
related_name='downtime_periods',
verbose_name="Отчёт"
)
start_time = models.TimeField(verbose_name="Время начала")
end_time = models.TimeField(verbose_name="Время окончания")
reason = models.TextField(verbose_name="Причина простоя")
def __str__(self):
return f"{self.start_time.strftime('%H:%M')}-{self.end_time.strftime('%H:%M')}: {self.reason[:30]}"
@property
def duration_hours(self):
"""Длительность простоя в часах"""
from datetime import datetime, timedelta
start = datetime.combine(datetime.today(), self.start_time)
end = datetime.combine(datetime.today(), self.end_time)
if end < start:
end += timedelta(days=1)
delta = end - start
return delta.total_seconds() / 3600
class Meta:
verbose_name = "Период простоя"
verbose_name_plural = "Периоды простоя"
ordering = ["start_time"]
class IssueMark(models.Model):
"""Отметка об ошибке/неисправности в отчёте"""
report = models.ForeignKey(
DailyReport,
on_delete=models.CASCADE,
related_name='issue_marks',
verbose_name="Отчёт"
)
issue_type = models.ForeignKey(
IssueType,
on_delete=models.CASCADE,
related_name='marks',
verbose_name="Тип ошибки/неисправности"
)
is_present = models.BooleanField(default=False, verbose_name="Наличие")
def __str__(self):
mark = "+" if self.is_present else "-"
return f"{self.report.date} - {self.issue_type.name}: {mark}"
class Meta:
verbose_name = "Отметка об ошибке"
verbose_name_plural = "Отметки об ошибках"
unique_together = ['report', 'issue_type']
ordering = ["issue_type__category", "issue_type__name"]

View File

@@ -63,6 +63,11 @@ PERMISSIONS = [
('transponder_delete', 'Удаление транспондера', 'Удаление транспондера'),
('transponder_import_xml', 'Импорт транспондеров из XML', 'Загрузка транспондеров из XML файла'),
# Errors Report (Журнал ошибок)
('errors_report_create', 'Создание записи журнала ошибок', 'Создание новой записи в журнале ошибок'),
('errors_report_edit', 'Редактирование записи журнала ошибок', 'Редактирование записи в журнале ошибок'),
('errors_report_delete', 'Удаление записи журнала ошибок', 'Удаление записи из журнала ошибок'),
# Admin access
# ('admin_access', 'Доступ к админ-панели', 'Доступ к административной панели Django'),
]
@@ -85,6 +90,7 @@ DEFAULT_ROLE_PERMISSIONS = {
'mark_create', 'mark_edit',
'statistics_view',
'kubsat_view', 'kubsat_edit',
'errors_report_create', 'errors_report_edit',
],
'user': [
'statistics_view',

View File

@@ -38,6 +38,9 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:errors_report' %}">Журнал ошибок</a>
</li>
{% if user|has_perm:'kubsat_view' %}
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>

View File

@@ -0,0 +1,586 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% load permission_tags %}
{% block title %}Журнал ошибок{% endblock %}
{% block extra_css %}
<style>
.mark-cell {
text-align: center;
font-weight: bold;
width: 60px;
}
.mark-positive {
background-color: #d4edda !important;
color: #155724;
}
.mark-negative {
background-color: #f8d7da !important;
color: #721c24;
}
.mark-empty {
color: #6c757d;
}
.downtime-cell {
font-size: 0.85em;
max-width: 250px;
}
.table-responsive {
max-height: calc(100vh - 250px);
overflow: auto;
}
.table thead th {
background: #343a40;
color: white;
font-size: 0.85em;
vertical-align: middle;
}
.table thead th.issue-header {
padding: 5px;
font-size: 0.75em;
min-width: 60px;
max-width: 100px;
word-wrap: break-word;
white-space: normal;
}
.filter-panel {
margin-bottom: 15px;
}
.btn-edit {
padding: 2px 6px;
font-size: 0.8em;
}
.weekly-hours-cell {
background-color: #f8f9fa;
font-weight: bold;
vertical-align: middle !important;
}
/* Модальное окно */
.downtime-item {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.downtime-item input[type="time"] {
width: 110px;
}
.downtime-item input[type="text"] {
flex: 1;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h4>Журнал ошибок и неисправностей</h4>
<div class="filter-panel d-flex gap-3 align-items-end flex-wrap">
<div>
<label class="form-label">Дата с:</label>
<input type="date" id="dateFrom" class="form-control form-control-sm">
</div>
<div>
<label class="form-label">Дата по:</label>
<input type="date" id="dateTo" class="form-control form-control-sm">
</div>
<button class="btn btn-primary btn-sm" onclick="loadData()">
Применить
</button>
{% if user|has_perm:'errors_report_create' %}
<button class="btn btn-success btn-sm" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Создать
</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm" id="reportTable">
<thead>
<tr id="headerRowTop">
</tr>
<tr id="headerRowBottom">
</tr>
</thead>
<tbody id="reportBody">
</tbody>
</table>
</div>
</div>
<!-- Модальное окно для создания/редактирования -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Новая запись</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editForm">
<input type="hidden" id="editId">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Дата</label>
<input type="date" id="editDate" class="form-control" required>
</div>
<div class="col-md-6">
<label class="form-label">Время работы за день (ч)</label>
<input type="number" step="0.01" min="0" id="editDailyHours" class="form-control" value="0">
</div>
</div>
<div class="mb-3">
<label class="form-label">Периоды простоя</label>
<div id="editDowntimeList"></div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2" onclick="addDowntimeRow()">
<i class="bi bi-plus"></i> Добавить период
</button>
</div>
<div class="row mb-3">
<div class="col-md-6">
<h6>Ошибки</h6>
<div id="editErrors"></div>
</div>
<div class="col-md-6">
<h6>Неисправности</h6>
<div id="editMalfunctions"></div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Пояснение</label>
<textarea id="editExplanation" class="form-control" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Комментарий</label>
<textarea id="editComment" class="form-control" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
{% if user|has_perm:'errors_report_delete' %}
<button type="button" class="btn btn-danger me-auto" id="btnDelete" onclick="deleteRecord()" style="display: none;">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="saveRecord()">Сохранить</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const issueTypes = {
errors: [
{% for e in errors %}
{id: {{ e.id }}, name: "{{ e.name|escapejs }}"},
{% endfor %}
],
malfunctions: [
{% for m in malfunctions %}
{id: {{ m.id }}, name: "{{ m.name|escapejs }}"},
{% endfor %}
]
};
let currentData = [];
let currentColumns = [];
let editModal = null;
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
editModal = new bootstrap.Modal(document.getElementById('editModal'));
loadData();
});
// Загрузка данных
async function loadData() {
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
let url = '{% url "mainapp:errors_report_api" %}?';
if (dateFrom) url += `date_from=${dateFrom}&`;
if (dateTo) url += `date_to=${dateTo}&`;
try {
const response = await fetch(url);
const result = await response.json();
currentData = result.data;
currentColumns = result.columns;
renderTable();
} catch (error) {
console.error('Ошибка загрузки:', error);
alert('Ошибка загрузки данных');
}
}
// Получить номер ISO недели для даты
function getWeekKey(dateStr) {
const date = new Date(dateStr);
// Получаем понедельник этой недели
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
const monday = new Date(date.setDate(diff));
// Формируем ключ: год-номередели
const year = monday.getFullYear();
const startOfYear = new Date(year, 0, 1);
const weekNum = Math.ceil(((monday - startOfYear) / 86400000 + startOfYear.getDay() + 1) / 7);
return `${year}-W${weekNum}`;
}
// Группировка данных по неделям и расчёт rowspan
function calculateWeekGroups(data) {
const weekGroups = {};
// Группируем записи по неделям
data.forEach((row, idx) => {
const weekKey = getWeekKey(row.date);
if (!weekGroups[weekKey]) {
weekGroups[weekKey] = {
rows: [],
totalHours: 0
};
}
weekGroups[weekKey].rows.push(idx);
weekGroups[weekKey].totalHours += parseFloat(row.daily_work_hours) || 0;
});
// Определяем для каждой строки: нужно ли рендерить ячейку и с каким rowspan
const rowInfo = {};
Object.values(weekGroups).forEach(group => {
const firstRowIdx = group.rows[0];
group.rows.forEach((idx, i) => {
rowInfo[idx] = {
isFirst: i === 0,
rowspan: group.rows.length,
weeklyTotal: group.totalHours.toFixed(2)
};
});
});
return rowInfo;
}
// Рендер таблицы
function renderTable() {
const errors = currentColumns.filter(c => c.category === 'error');
const malfunctions = currentColumns.filter(c => c.category === 'malfunction');
// Верхний ряд заголовков
const headerRowTop = document.getElementById('headerRowTop');
let topHtml = `
<th rowspan="2" style="width: 50px;"></th>
<th rowspan="2" style="width: 120px;">Дата</th>
<th rowspan="2" style="min-width: 200px;">Простои</th>
`;
if (errors.length > 0) {
topHtml += `<th colspan="${errors.length}" class="text-center">Ошибки</th>`;
}
if (malfunctions.length > 0) {
topHtml += `<th colspan="${malfunctions.length}" class="text-center">Неисправности</th>`;
}
topHtml += `
<th rowspan="2" style="width: 80px;">Раб. ч/день</th>
<th rowspan="2" style="width: 100px;">Раб. ч/нед.</th>
<th rowspan="2" style="min-width: 150px;">Пояснение</th>
<th rowspan="2" style="min-width: 150px;">Комментарий</th>
`;
headerRowTop.innerHTML = topHtml;
// Нижний ряд заголовков (названия ошибок/неисправностей)
const headerRowBottom = document.getElementById('headerRowBottom');
let bottomHtml = '';
errors.forEach(col => {
bottomHtml += `<th class="issue-header" title="${col.name}">${col.name}</th>`;
});
malfunctions.forEach(col => {
bottomHtml += `<th class="issue-header" title="${col.name}">${col.name}</th>`;
});
headerRowBottom.innerHTML = bottomHtml;
// Рендерим строки
const tbody = document.getElementById('reportBody');
if (currentData.length === 0) {
tbody.innerHTML = '<tr><td colspan="100" class="text-center text-muted">Нет данных</td></tr>';
return;
}
// Рассчитываем группы по неделям
const weekInfo = calculateWeekGroups(currentData);
let bodyHtml = '';
currentData.forEach((row, idx) => {
const date = new Date(row.date);
const dateStr = date.toLocaleDateString('ru-RU');
const dayStr = date.toLocaleDateString('ru-RU', { weekday: 'short' });
const fullDateStr = dateStr + ' (' + dayStr + ')';
const info = weekInfo[idx];
bodyHtml += `<tr>`;
{% if user|has_perm:'errors_report_edit' %}
bodyHtml += `<td><button class="btn btn-outline-primary btn-edit" onclick="openEditModal(${row.id})"><i class="bi bi-pencil"></i></button></td>`;
{% else %}
bodyHtml += `<td>${row.id}</td>`;
{% endif %}
bodyHtml += `<td>${fullDateStr}</td>`;
bodyHtml += `<td class="downtime-cell">${row.downtimes_display || '—'}</td>`;
// Ошибки
errors.forEach(col => {
const value = row[col.field];
bodyHtml += renderMarkCell(value);
});
// Неисправности
malfunctions.forEach(col => {
const value = row[col.field];
bodyHtml += renderMarkCell(value);
});
bodyHtml += `<td class="text-center">${row.daily_work_hours || 0}</td>`;
// Ячейка "Раб. ч/нед." с rowspan
if (info.isFirst) {
bodyHtml += `<td class="text-center align-middle weekly-hours-cell" rowspan="${info.rowspan}">${info.weeklyTotal}</td>`;
}
bodyHtml += `<td>${row.explanation || ''}</td>`;
bodyHtml += `<td>${row.comment || ''}</td>`;
bodyHtml += `</tr>`;
});
tbody.innerHTML = bodyHtml;
}
// Рендер ячейки с отметкой
function renderMarkCell(value) {
if (value === true) {
return '<td class="mark-cell mark-positive"><i class="bi bi-check-lg"></i></td>';
} else if (value === false) {
return '<td class="mark-cell mark-negative"><i class="bi bi-x-lg"></i></td>';
}
return '<td class="mark-cell mark-empty">—</td>';
}
// Открыть модальное окно для создания
function openCreateModal() {
document.getElementById('modalTitle').textContent = 'Новая запись';
document.getElementById('editId').value = '';
document.getElementById('editDate').value = new Date().toISOString().split('T')[0];
document.getElementById('editDailyHours').value = 0;
document.getElementById('editExplanation').value = '';
document.getElementById('editComment').value = '';
const btnDelete = document.getElementById('btnDelete');
if (btnDelete) btnDelete.style.display = 'none';
// Очищаем простои
document.getElementById('editDowntimeList').innerHTML = '';
// Заполняем чекбоксы
renderIssueCheckboxes({});
editModal.show();
}
// Открыть модальное окно для редактирования
async function openEditModal(id) {
const row = currentData.find(r => r.id === id);
if (!row) return;
document.getElementById('modalTitle').textContent = 'Редактирование записи';
document.getElementById('editId').value = row.id;
document.getElementById('editDate').value = row.date;
document.getElementById('editDailyHours').value = row.daily_work_hours || 0;
document.getElementById('editExplanation').value = row.explanation || '';
document.getElementById('editComment').value = row.comment || '';
const btnDelete = document.getElementById('btnDelete');
if (btnDelete) btnDelete.style.display = 'block';
// Заполняем простои
renderDowntimes(row.downtimes || []);
// Заполняем чекбоксы
renderIssueCheckboxes(row);
editModal.show();
}
// Рендер периодов простоя
function renderDowntimes(downtimes) {
const container = document.getElementById('editDowntimeList');
let html = '';
downtimes.forEach((dt, idx) => {
html += createDowntimeRowHtml(idx, dt.start || '', dt.end || '', dt.reason || '');
});
container.innerHTML = html;
}
// Создать HTML для строки простоя
function createDowntimeRowHtml(idx, start, end, reason) {
return `
<div class="downtime-item" data-idx="${idx}">
<input type="time" class="form-control form-control-sm dt-start" value="${start}">
<span>—</span>
<input type="time" class="form-control form-control-sm dt-end" value="${end}">
<input type="text" class="form-control form-control-sm dt-reason" placeholder="Причина" value="${reason}">
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeDowntimeRow(this)">
<i class="bi bi-trash"></i>
</button>
</div>
`;
}
// Добавить период простоя
function addDowntimeRow() {
const container = document.getElementById('editDowntimeList');
const items = container.querySelectorAll('.downtime-item');
const newIdx = items.length;
// Создаём новый элемент без затрагивания существующих
const template = document.createElement('template');
template.innerHTML = createDowntimeRowHtml(newIdx, '', '', '').trim();
container.appendChild(template.content.firstChild);
}
// Удалить период простоя
function removeDowntimeRow(btn) {
btn.closest('.downtime-item').remove();
}
// Рендер чекбоксов ошибок/неисправностей
function renderIssueCheckboxes(row) {
let errorsHtml = '';
issueTypes.errors.forEach(e => {
const checked = row[`issue_${e.id}`] ? 'checked' : '';
errorsHtml += `
<div class="form-check">
<input class="form-check-input issue-check" type="checkbox" id="issue_${e.id}" data-issue-id="${e.id}" ${checked}>
<label class="form-check-label" for="issue_${e.id}">${e.name}</label>
</div>
`;
});
document.getElementById('editErrors').innerHTML = errorsHtml;
let malfHtml = '';
issueTypes.malfunctions.forEach(m => {
const checked = row[`issue_${m.id}`] ? 'checked' : '';
malfHtml += `
<div class="form-check">
<input class="form-check-input issue-check" type="checkbox" id="issue_${m.id}" data-issue-id="${m.id}" ${checked}>
<label class="form-check-label" for="issue_${m.id}">${m.name}</label>
</div>
`;
});
document.getElementById('editMalfunctions').innerHTML = malfHtml;
}
// Собрать данные простоев из формы
function collectDowntimes() {
const items = document.querySelectorAll('#editDowntimeList .downtime-item');
const result = [];
items.forEach(item => {
const start = item.querySelector('.dt-start').value;
const end = item.querySelector('.dt-end').value;
const reason = item.querySelector('.dt-reason').value;
if (start && end) {
result.push({start, end, reason});
}
});
return result;
}
// Сохранение записи
async function saveRecord() {
const date = document.getElementById('editDate').value;
if (!date) {
alert('Укажите дату');
return;
}
// Собираем отметки
const issueMarks = {};
document.querySelectorAll('.issue-check').forEach(cb => {
issueMarks[cb.dataset.issueId] = cb.checked;
});
const data = {
id: document.getElementById('editId').value || null,
date: date,
daily_work_hours: parseFloat(document.getElementById('editDailyHours').value) || 0,
explanation: document.getElementById('editExplanation').value,
comment: document.getElementById('editComment').value,
downtimes: collectDowntimes(),
issue_marks: issueMarks
};
try {
const response = await fetch('{% url "mainapp:errors_report_save" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
editModal.hide();
loadData();
} else {
alert('Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка сохранения:', error);
alert('Ошибка сохранения');
}
}
// Удаление записи
async function deleteRecord() {
const id = document.getElementById('editId').value;
if (!id) return;
if (!confirm('Удалить эту запись?')) return;
try {
const response = await fetch('{% url "mainapp:errors_report_delete" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
editModal.hide();
loadData();
} else {
alert('Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
</script>
{% endblock %}

View File

@@ -101,6 +101,13 @@ from .views.user_permissions import (
UserPermissionsApiView,
InitPermissionsView,
)
from .views.errors_report import (
ErrorsReportView,
ErrorsReportAPIView,
ErrorsReportSaveAPIView,
ErrorsReportDeleteAPIView,
WeeklyHoursAPIView,
)
app_name = 'mainapp'
@@ -208,4 +215,11 @@ urlpatterns = [
path('user-permissions/<int:pk>/edit/', UserPermissionsEditView.as_view(), name='user_permissions_edit'),
path('user-permissions/init/', InitPermissionsView.as_view(), name='init_permissions'),
path('api/user-permissions/<int:pk>/', UserPermissionsApiView.as_view(), name='user_permissions_api'),
# Errors Report (Журнал ошибок)
path('errors-report/', ErrorsReportView.as_view(), name='errors_report'),
path('api/errors-report/', ErrorsReportAPIView.as_view(), name='errors_report_api'),
path('api/errors-report/save/', ErrorsReportSaveAPIView.as_view(), name='errors_report_save'),
path('api/errors-report/delete/', ErrorsReportDeleteAPIView.as_view(), name='errors_report_delete'),
path('api/errors-report/weekly-hours/', WeeklyHoursAPIView.as_view(), name='weekly_hours_api'),
]

View File

@@ -85,6 +85,13 @@ from .source_requests import (
SourceRequestAPIView,
SourceRequestDetailAPIView,
)
from .errors_report import (
ErrorsReportView,
ErrorsReportAPIView,
ErrorsReportSaveAPIView,
ErrorsReportDeleteAPIView,
WeeklyHoursAPIView,
)
__all__ = [
# Base
@@ -170,4 +177,10 @@ __all__ = [
'SourceRequestDeleteView',
'SourceRequestAPIView',
'SourceRequestDetailAPIView',
# Errors Report
'ErrorsReportView',
'ErrorsReportAPIView',
'ErrorsReportSaveAPIView',
'ErrorsReportDeleteAPIView',
'WeeklyHoursAPIView',
]

View File

@@ -0,0 +1,218 @@
import json
from datetime import datetime, timedelta
from decimal import Decimal
from django.contrib.auth.mixins import LoginRequiredMixin
from mainapp.permissions import PermissionRequiredMixin
from django.views.generic import TemplateView, View
from django.http import JsonResponse
from django.db import transaction
from django.db.models import Sum
from mainapp.models import IssueType, DailyReport, DowntimePeriod, IssueMark
class ErrorsReportView(TemplateView, LoginRequiredMixin, PermissionRequiredMixin):
"""Страница отчётов об ошибках"""
template_name = 'mainapp/errors_report.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['issue_types'] = IssueType.objects.all().order_by('category', 'name')
context['errors'] = context['issue_types'].filter(category='error')
context['malfunctions'] = context['issue_types'].filter(category='malfunction')
context['full_width_page'] = True
return context
class ErrorsReportAPIView(View, LoginRequiredMixin, PermissionRequiredMixin):
"""API для получения данных отчётов"""
def get(self, request):
# Получаем параметры фильтрации
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
reports = DailyReport.objects.all()
if date_from:
reports = reports.filter(date__gte=date_from)
if date_to:
reports = reports.filter(date__lte=date_to)
reports = reports.prefetch_related('downtime_periods', 'issue_marks__issue_type')
# Получаем все типы ошибок/неисправностей
issue_types = IssueType.objects.all().order_by('category', 'name')
data = []
for report in reports:
# Формируем строку простоев
downtimes = []
for dt in report.downtime_periods.all():
downtimes.append({
'id': dt.id,
'start': dt.start_time.strftime('%H:%M'),
'end': dt.end_time.strftime('%H:%M'),
'reason': dt.reason
})
# Формируем отметки
marks_dict = {m.issue_type_id: m.is_present for m in report.issue_marks.all()}
row = {
'id': report.id,
'date': report.date.isoformat(),
'downtimes': downtimes,
'downtimes_display': '; '.join([f"{d['start']}-{d['end']} ({d['reason']})" for d in downtimes]),
'daily_work_hours': float(report.daily_work_hours),
'weekly_work_hours': float(report.weekly_work_hours),
'explanation': report.explanation or '',
'comment': report.comment or '',
}
# Добавляем отметки по каждому типу
for it in issue_types:
row[f'issue_{it.id}'] = marks_dict.get(it.id, False)
data.append(row)
# Формируем информацию о колонках
columns = []
for it in issue_types:
columns.append({
'id': it.id,
'name': it.name,
'category': it.category,
'field': f'issue_{it.id}'
})
return JsonResponse({
'data': data,
'columns': columns
})
class ErrorsReportSaveAPIView(View):
"""API для сохранения отчёта"""
def post(self, request):
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400)
date_str = data.get('date')
if not date_str:
return JsonResponse({'success': False, 'error': 'Дата обязательна'}, status=400)
try:
report_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return JsonResponse({'success': False, 'error': 'Неверный формат даты'}, status=400)
report_id = data.get('id')
# Проверка на дублирование даты при создании новой записи
if not report_id:
if DailyReport.objects.filter(date=report_date).exists():
return JsonResponse({
'success': False,
'error': f'Запись за {report_date.strftime("%d.%m.%Y")} уже существует'
}, status=400)
with transaction.atomic():
if report_id:
# Обновление существующей записи
try:
report = DailyReport.objects.get(id=report_id)
report.date = report_date
report.daily_work_hours = Decimal(str(data.get('daily_work_hours', 0)))
report.explanation = data.get('explanation', '')
report.comment = data.get('comment', '')
report.save()
except DailyReport.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Запись не найдена'}, status=404)
else:
# Создание новой записи
report = DailyReport.objects.create(
date=report_date,
daily_work_hours=Decimal(str(data.get('daily_work_hours', 0))),
explanation=data.get('explanation', ''),
comment=data.get('comment', ''),
created_by=request.user if request.user.is_authenticated else None,
)
# Обновляем периоды простоя
downtimes = data.get('downtimes', [])
report.downtime_periods.all().delete()
for dt in downtimes:
if dt.get('start') and dt.get('end'):
DowntimePeriod.objects.create(
report=report,
start_time=dt['start'],
end_time=dt['end'],
reason=dt.get('reason', '')
)
# Обновляем отметки
issue_marks = data.get('issue_marks', {})
for issue_id_str, is_present in issue_marks.items():
issue_id = int(issue_id_str)
IssueMark.objects.update_or_create(
report=report,
issue_type_id=issue_id,
defaults={'is_present': is_present}
)
return JsonResponse({'success': True, 'id': report.id})
class ErrorsReportDeleteAPIView(View):
"""API для удаления отчёта"""
def post(self, request):
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400)
report_id = data.get('id')
if not report_id:
return JsonResponse({'success': False, 'error': 'ID отчёта обязателен'}, status=400)
try:
report = DailyReport.objects.get(id=report_id)
report.delete()
return JsonResponse({'success': True})
except DailyReport.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Отчёт не найден'}, status=404)
class WeeklyHoursAPIView(View):
"""API для расчёта часов за неделю"""
def get(self, request):
date_str = request.GET.get('date')
if not date_str:
return JsonResponse({'error': 'Дата обязательна'}, status=400)
try:
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return JsonResponse({'error': 'Неверный формат даты'}, status=400)
# Находим начало недели (понедельник)
week_start = target_date - timedelta(days=target_date.weekday())
week_end = week_start + timedelta(days=6)
# Считаем сумму часов за неделю
total = DailyReport.objects.filter(
date__gte=week_start,
date__lte=target_date
).aggregate(total=Sum('daily_work_hours'))['total'] or 0
return JsonResponse({
'week_start': week_start.isoformat(),
'week_end': week_end.isoformat(),
'total_hours': float(total)
})

View File

@@ -59,6 +59,7 @@ class UserPermissionsEditView(LoginRequiredMixin, PermissionRequiredMixin, View)
'Транспондеры': [],
'Тех. анализ': [],
'Отметки': [],
'Журнал ошибок': [],
'Прочее': [],
}
@@ -85,6 +86,8 @@ class UserPermissionsEditView(LoginRequiredMixin, PermissionRequiredMixin, View)
permission_groups['Тех. анализ'].append(perm_data)
elif code.startswith('mark_'):
permission_groups['Отметки'].append(perm_data)
elif code.startswith('errors_report_'):
permission_groups['Журнал ошибок'].append(perm_data)
else:
permission_groups['Прочее'].append(perm_data)