Доработали фильт в реестре заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s

This commit is contained in:
2026-03-29 20:29:05 +03:00
parent 7ef7409c7a
commit 6013d5854b
22 changed files with 431 additions and 63 deletions

2
.env
View File

@@ -4,8 +4,6 @@ DB_USER=prodman_user
DB_PASS=prodman_password_zwE45t!
# Настройки Django
# ENV_TYPE=dev
# ENV_TYPE=server
DB_HOST=db
SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms'
# todo потом установить домен для продакшена

View File

@@ -63,6 +63,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'shiftflow', # Вот это допиши обязательно!
'warehouse',
]
MIDDLEWARE = [

View File

@@ -16,10 +16,13 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from django.templatetags.static import static as static_url
from django.conf.urls.static import static # <--- Добавьте эту строку
from core import settings
urlpatterns = [
path('favicon.ico', RedirectView.as_view(url=static_url('favicon.svg'), permanent=True)),
path('admin/', admin.site.urls),
# Добавь эту строку, она подключит login, logout и прочие стандартные пути
path('accounts/', include('django.contrib.auth.urls')),

View File

@@ -1,6 +1,6 @@
import os
from django.contrib import admin
from .models import Company, EmployeeProfile, Machine, Deal, Material, ProductionTask, Item
from .models import Company, EmployeeProfile, Machine, Deal, ProductionTask, Item
# --- Настройка отображения Компаний ---
@admin.register(Company)
@@ -15,11 +15,6 @@ class DealAdmin(admin.ModelAdmin):
search_fields = ('number', 'company__name')
list_filter = ('company',)
# --- Настройка отображения Материалов ---
@admin.register(Material)
class MaterialAdmin(admin.ModelAdmin):
search_fields = ('name',)
# --- Задания на производство (База) ---
@admin.register(ProductionTask)
class ProductionTaskAdmin(admin.ModelAdmin):
@@ -50,10 +45,12 @@ class ItemAdmin(admin.ModelAdmin):
}),
)
def get_deal(self, obj): return obj.task.deal
def get_deal(self, obj):
return obj.task.deal if obj.task else "-"
get_deal.short_description = 'Сделка'
def get_drawing(self, obj): return obj.task.drawing_name
def get_drawing(self, obj):
return obj.task.drawing_name if obj.task else "-"
get_drawing.short_description = 'Деталь'
# Регистрация станков просто списком

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-29 14:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0004_alter_item_options_remove_item_deal_and_more'),
('warehouse', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='productiontask',
name='material',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
),
migrations.DeleteModel(
name='Material',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.3 on 2026-03-29 16:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0005_alter_productiontask_material_delete_material'),
]
operations = [
migrations.AlterModelOptions(
name='item',
options={'ordering': ['-date', 'task__deal'], 'verbose_name': 'Пункт сменки', 'verbose_name_plural': 'Реестр сменных заданий'},
),
migrations.AlterField(
model_name='item',
name='status',
field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел'), ('imported', 'Импортировано')], default='work', max_length=10, verbose_name='Статус'),
),
]

View File

@@ -1,6 +1,7 @@
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from warehouse.models import Material as WarehouseMaterial
class Company(models.Model):
"""
@@ -38,17 +39,6 @@ class Deal(models.Model):
class Meta:
verbose_name = "Сделка"; verbose_name_plural = "Сделки"
class Material(models.Model):
"""
Справочник ТМЦ (Трубы, листы, профили).
Необходим для точного списания остатков и синхронизации с 1С.
"""
name = models.CharField("Наименование", max_length=255, unique=True)
def __str__(self): return self.name
class Meta:
verbose_name = "Материал"; verbose_name_plural = "Материалы"
class ProductionTask(models.Model):
"""
Основание для производства. Определяет ЧТО делать.
@@ -62,7 +52,7 @@ class ProductionTask(models.Model):
drawing_file = models.FileField("Исходник (DXF/IGES)", upload_to="drawings/%Y/%m/", blank=True, null=True)
extra_drawing = models.FileField("Доп. чертеж (PDF)", upload_to="extra_drawings/%Y/%m/", blank=True, null=True)
material = models.ForeignKey(Material, on_delete=models.PROTECT, verbose_name="Материал")
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name="Материал")
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
is_bend = models.BooleanField("Гибка", default=False)
@@ -84,6 +74,7 @@ class Item(models.Model):
('done', 'Выполнено'),
('partial', 'Частично'),
('leftover', 'Недодел'),
('imported', 'Импортировано'),
]
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
@@ -106,11 +97,13 @@ class Item(models.Model):
is_synced_1c = models.BooleanField("Учтено в 1С", default=False)
class Meta:
verbose_name = "Позиция сменки"; verbose_name_plural = "Реестр сменных заданий"
verbose_name = "Пункт сменки"; verbose_name_plural = "Реестр сменных заданий"
ordering = ['-date', 'task__deal']
def __str__(self):
return f"{self.task.drawing_name} - {self.date}"
if self.task:
return f"{self.task.drawing_name} - {self.date}"
return f"Без задания - {self.date}"
class EmployeeProfile(models.Model):

View File

@@ -52,6 +52,21 @@
<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">
<label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
</div>
<div class="col-md-4">
<label class="small text-muted">Деловой отход</label>
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: кусок 1500мм">
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight }}">
</div>
</div>
</div>
{% else %}
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
@@ -120,7 +135,7 @@ function closeTask(status) {
function showPartial() {
document.getElementById('partialInput').classList.remove('d-none');
document.getElementById('id_status').value = 'part'; // Статус Частично
document.getElementById('id_status').value = 'partial';
}
</script>
{% endblock %}

View File

@@ -1,45 +1,81 @@
<div class="card border-secondary mb-3 shadow-sm">
<div class="card-body py-2">
<form method="get" id="filter-form" class="row g-2 align-items-center">
<input type="hidden" name="filtered" value="1"> <div class="col-md-4">
<input type="hidden" name="filtered" value="1">
{% if user_role != 'operator' %}
<div class="col-md-4">
<div class="small text-muted mb-1 fw-bold">Станки:</div>
<div class="d-flex flex-wrap gap-1">
{% for m in machines %}
<div>
<input type="checkbox" class="btn-check" name="m_ids" id="m_{{ m.id }}" value="{{ m.id }}"
{% if m.id in selected_machines %}checked{% endif %} onchange="this.form.submit()">
{% if all_selected_machines or m.id in selected_machines %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-accent btn-sm" for="m_{{ m.id }}">{{ m.name }}</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="col-md-3">
<div class="small text-muted mb-1 fw-bold">Статус:</div>
<div class="d-flex flex-wrap gap-1">
<input type="checkbox" class="btn-check" name="statuses" id="s_work" value="work" {% if 'work' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
<input type="checkbox" class="btn-check" name="statuses" id="s_partial" value="partial" {% if 'partial' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-danger btn-sm" for="s_partial">Недодел</label>
{% if user_role == 'operator' %}
<input type="hidden" name="statuses" value="work">
<span class="badge bg-primary">В работе</span>
{% else %}
<input type="checkbox" class="btn-check" name="statuses" id="s_work" value="work" {% if 'work' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
<input type="checkbox" class="btn-check" name="statuses" id="s_leftover" value="leftover" {% if 'leftover' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-danger btn-sm" for="s_leftover">Недодел</label>
<input type="checkbox" class="btn-check" name="statuses" id="s_done" value="done" {% if 'done' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-success btn-sm" for="s_done">Завершено</label>
<input type="checkbox" class="btn-check" name="statuses" id="s_closed" value="closed" {% if 'closed' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-success btn-sm" for="s_closed">Завершено</label>
{% if user_role in 'admin,technologist' %}
<input type="checkbox" class="btn-check" name="statuses" id="s_imported" value="imported" {% if 'imported' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-accent btn-sm" for="s_imported">Импорт</label>
{% endif %}
{% endif %}
</div>
</div>
{% if user_role in 'admin,technologist,clerk' %}
<div class="col-md-2">
<label class="small text-muted mb-1 fw-bold">Учёт 1С:</label>
<select name="is_synced" class="form-select form-select-sm bg-body text-body border-secondary" onchange="this.form.submit()">
<option value="" {% if not is_synced %}selected{% endif %}>Все</option>
<option value="1" {% if is_synced == '1' %}selected{% endif %}>Учтено</option>
<option value="0" {% if is_synced == '0' %}selected{% endif %}>Ожидает</option>
</select>
</div>
{% endif %}
<div class="col-md-2">
<label class="small text-muted mb-1 fw-bold">С:</label>
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ start_date }}" onchange="this.form.submit()">
<input type="date" name="start_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ start_date }}" onchange="this.form.submit()">
</div>
<div class="col-md-2">
<label class="small text-muted mb-1 fw-bold">По:</label>
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ end_date }}" onchange="this.form.submit()">
<input type="date" name="end_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ end_date }}" onchange="this.form.submit()">
</div>
<div class="col-md-1 text-end mt-auto">
<a href="{% url 'registry' %}" class="btn btn-outline-secondary btn-sm w-100" title="Сбросить по умолчанию"><i class="bi bi-x-circle"></i></a>
<a href="{% url 'registry' %}" class="btn btn-outline-secondary btn-sm w-100" title="Сброс">
<i class="bi bi-arrow-counterclockwise me-1"></i>Сброс
</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function(){
const s = document.querySelector('input[name="start_date"]');
const e = document.querySelector('input[name="end_date"]');
const today = new Date().toISOString().slice(0,10);
if (s && !s.value) s.value = today;
if (e && !e.value) e.value = today;
});
</script>
</div>
</div>

View File

@@ -1,6 +1,8 @@
{% extends 'base.html' %}
{% block content %}
{% include 'shiftflow/partials/_filter.html' %}
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3">
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
@@ -27,23 +29,23 @@
{% 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.deal.number }}</span></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.drawing_name }}</td>
<td class="small">{{ item.size_value }}</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.material.name }}</td>
<td class="small text-muted">{{ item.task.material.full_name|default:item.task.material.name|default:"-" }}</td>
<td class="text-center">
{% if item.drawing_file %}
<a href="{{ item.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
{% 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.extra_drawing %}
<a href="{{ item.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
{% 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 %}

View File

@@ -2,7 +2,8 @@ from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import TemplateView, ListView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Item # Проверь, как точно называется твоя модель деталей/заказов
from django.utils import timezone
from .models import Item, Machine
# Класс главной страницы (роутер)
class IndexView(TemplateView):
@@ -22,22 +23,84 @@ class RegistryView(LoginRequiredMixin, ListView):
context_object_name = 'items'
def get_queryset(self):
# Оптимизируем запросы, подгружая связанные данные сразу
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine').all()
# Если это оператор, показываем только задания для его станков
if hasattr(self.request.user, 'profile') and self.request.user.profile.role == 'operator':
user_machines = self.request.user.profile.machines.all()
if user_machines.exists():
queryset = queryset.filter(machine__in=user_machines)
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine')
user = self.request.user
profile = getattr(user, 'profile', None)
role = profile.role if profile else 'operator'
filtered = self.request.GET.get('filtered')
# Станки
m_ids = self.request.GET.getlist('m_ids')
if filtered and role != 'operator' and not m_ids:
return queryset.none()
if m_ids:
queryset = queryset.filter(machine_id__in=m_ids)
# Статусы (+ агрегат "closed" = done+partial)
statuses = self.request.GET.getlist('statuses')
if filtered and not statuses:
return queryset.none()
if statuses:
expanded = []
for s in statuses:
if s == 'closed':
expanded += ['done', 'partial']
else:
expanded.append(s)
queryset = queryset.filter(status__in=expanded)
# Даты
start_date = self.request.GET.get('start_date')
end_date = self.request.GET.get('end_date')
if not filtered:
today = timezone.now().date()
queryset = queryset.filter(date=today, status__in=['work', 'leftover'])
else:
if start_date:
queryset = queryset.filter(date__gte=start_date)
if end_date:
queryset = queryset.filter(date__lte=end_date)
# Списание (1С)
is_synced = self.request.GET.get('is_synced')
if is_synced in ['0', '1']:
queryset = queryset.filter(is_synced_1c=bool(int(is_synced)))
# Ограничения по ролям
if role == 'operator':
user_machines = profile.machines.all() if profile else Machine.objects.none()
queryset = queryset.filter(machine__in=user_machines, status='work')
elif role == 'master' and not filtered:
queryset = queryset.filter(status='work')
return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Передаем роль в шаблон, чтобы скрывать/показывать кнопки
if hasattr(self.request.user, 'profile'):
context['user_role'] = self.request.user.profile.role
user = self.request.user
profile = getattr(user, 'profile', None)
role = profile.role if profile else 'operator'
context['user_role'] = role
machines = Machine.objects.all()
context['machines'] = machines
filtered = self.request.GET.get('filtered')
if not filtered:
today_str = timezone.now().date().strftime('%Y-%m-%d')
context['start_date'] = today_str
context['end_date'] = today_str
context['selected_statuses'] = ['work', 'leftover']
context['selected_machines'] = [m.id for m in machines]
context['all_selected_machines'] = True
else:
context['selected_machines'] = [int(i) for i in self.request.GET.getlist('m_ids') if i.isdigit()]
context['selected_statuses'] = self.request.GET.getlist('statuses')
context['start_date'] = self.request.GET.get('start_date', '')
context['end_date'] = self.request.GET.get('end_date', '')
context['is_synced'] = self.request.GET.get('is_synced', '')
context['all_selected_machines'] = False
return context
# Вьюха детального вида и редактирования
@@ -59,6 +122,54 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
context['user_role'] = self.request.user.profile.role
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else 'operator'
# Общие поля
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.scrap_weight = request.POST.get('scrap_weight', self.object.scrap_weight or 0)
status = request.POST.get('status', self.object.status)
if role in ['operator', 'master']:
if status == 'done':
self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done'
self.object.save()
elif status == 'partial':
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,
)
else:
# Просто сохранить без спец-логики
return super().post(request, *args, **kwargs)
elif role == 'clerk':
# Учетчик может отмечать списание 1С
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
self.object.save()
else:
return super().post(request, *args, **kwargs)
return redirect('registry')
def get_success_url(self):
# После сохранения возвращаемся в реестр
return reverse_lazy('registry')

View File

@@ -20,7 +20,8 @@ body {
.navbar .nav-link,
.navbar .navbar-brand,
.footer-custom span,
.footer-custom strong {
.footer-custom strong,
.footer-custom .text-muted {
color: #e9ecef !important;
}
@@ -62,6 +63,11 @@ body {
--bs-accent: #0d6efd; /* Синий акцент для светлой темы */
}
[data-bs-theme="dark"] input[type="date"] { color-scheme: dark; }
[data-bs-theme="dark"] .form-control[type="date"] { background-color: #1e1e1e; border-color: #3d4246; color: #e9ecef; }
[data-bs-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) brightness(1.4) contrast(1.2); opacity: 0.95; }
[data-bs-theme="light"] input[type="date"] { color-scheme: light; }
/* --- ТАБЛИЦА И КАРТОЧКИ --- */
/* Заголовок таблицы: всегда темный с акцентным текстом */
@@ -90,10 +96,25 @@ body {
border-color: var(--bs-accent) !important;
}
/* Состояние кнопки при наведении */
.btn-outline-accent:hover {
.btn-check:checked + .btn-outline-accent,
.btn-outline-accent.active,
.btn-outline-accent:active {
background-color: var(--bs-accent) !important;
color: #000 !important; /* Текст становится черным для контраста */
border-color: var(--bs-accent) !important;
}
[data-bs-theme="dark"] .btn-outline-accent:hover,
[data-bs-theme="dark"] .btn-check:checked + .btn-outline-accent,
[data-bs-theme="dark"] .btn-outline-accent.active,
[data-bs-theme="dark"] .btn-outline-accent:active {
color: #212529 !important;
}
[data-bs-theme="light"] .btn-outline-accent:hover,
[data-bs-theme="light"] .btn-check:checked + .btn-outline-accent,
[data-bs-theme="light"] .btn-outline-accent.active,
[data-bs-theme="light"] .btn-outline-accent:active {
color: #ffffff !important;
}
/* Специальный класс для центрирования окна логина (вернем его только там) */

3
static/favicon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#ffc107" d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ShiftFlow MES{% endblock %}</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="{% static 'css/style.css' %}">

0
warehouse/__init__.py Normal file
View File

19
warehouse/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import MaterialCategory, SteelGrade, Material
@admin.register(MaterialCategory)
class MaterialCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'gost_standard')
search_fields = ('name', 'gost_standard')
@admin.register(SteelGrade)
class SteelGradeAdmin(admin.ModelAdmin):
list_display = ('name', 'gost_standard')
search_fields = ('name', 'gost_standard')
@admin.register(Material)
class MaterialAdmin(admin.ModelAdmin):
list_display = ('full_name', 'category', 'steel_grade', 'name')
list_filter = ('category', 'steel_grade')
search_fields = ('name', 'full_name')
readonly_fields = ('full_name',)

6
warehouse/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class WarehouseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'warehouse'
verbose_name = 'Склад и материалы'

View File

@@ -0,0 +1,53 @@
# Generated by Django 6.0.3 on 2026-03-29 14:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MaterialCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Название категории')),
],
options={
'verbose_name': 'Категория материала',
'verbose_name_plural': 'Категории материалов',
},
),
migrations.CreateModel(
name='SteelGrade',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Марка стали')),
('gost_standard', models.CharField(blank=True, help_text='Основной стандарт для этой марки', max_length=255, verbose_name='ГОСТ/ТУ')),
('certificate_pdf', models.FileField(blank=True, null=True, upload_to='certificates/', verbose_name='Сертификат/ГОСТ (PDF)')),
],
options={
'verbose_name': 'Марка стали',
'verbose_name_plural': 'Марки стали',
},
),
migrations.CreateModel(
name='Material',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Наименование (размер/характеристики)')),
('full_name', models.CharField(blank=True, help_text='Генерируется автоматически, если пусто', max_length=500, verbose_name='Полное наименование')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.materialcategory', verbose_name='Категория')),
('steel_grade', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.steelgrade', verbose_name='Марка стали')),
],
options={
'verbose_name': 'Материал (номенклатура)',
'verbose_name_plural': 'Материалы',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-29 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('warehouse', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='materialcategory',
name='gost_standard',
field=models.CharField(blank=True, help_text='Напр: ГОСТ 8639-82', max_length=255, verbose_name='ГОСТ на тип проката'),
),
]

View File

46
warehouse/models.py Normal file
View File

@@ -0,0 +1,46 @@
from django.db import models
class MaterialCategory(models.Model):
"""Категория материала (например, Труба, Лист, Круг)"""
name = models.CharField("Название категории", max_length=100, unique=True)
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
class Meta:
verbose_name = "Категория материала"
verbose_name_plural = "Категории материалов"
def __str__(self):
return self.name
class SteelGrade(models.Model):
"""Марка стали (например, Ст3сп, 09Г2С) и связанные с ней ГОСТы"""
name = models.CharField("Марка стали", max_length=100, unique=True)
gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True, help_text="Основной стандарт для этой марки")
certificate_pdf = models.FileField("Сертификат/ГОСТ (PDF)", upload_to='certificates/', blank=True, null=True)
class Meta:
verbose_name = "Марка стали"
verbose_name_plural = "Марки стали"
def __str__(self):
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
class Material(models.Model):
"""Конкретная номенклатурная единица (например, Труба 100х100х4)"""
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически, если пусто")
class Meta:
verbose_name = "Материал (номенклатура)"
verbose_name_plural = "Материалы"
def save(self, *args, **kwargs):
if not self.full_name:
grade_str = f" {self.steel_grade.name}" if self.steel_grade else ""
self.full_name = f"{self.category.name} {self.name}{grade_str}"
super().save(*args, **kwargs)
def __str__(self):
return self.full_name or f"{self.category.name} {self.name}"

0
warehouse/views.py Normal file
View File