Доработали фильт в реестре заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
This commit is contained in:
2
.env
2
.env
@@ -4,8 +4,6 @@ DB_USER=prodman_user
|
|||||||
DB_PASS=prodman_password_zwE45t!
|
DB_PASS=prodman_password_zwE45t!
|
||||||
|
|
||||||
# Настройки Django
|
# Настройки Django
|
||||||
# ENV_TYPE=dev
|
|
||||||
# ENV_TYPE=server
|
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms'
|
SECRET_KEY='django-insecure-9p*t_(vtwo144=u)ie8qzb31a8%i(&1w4$0vq#udautexj^vms'
|
||||||
# todo потом установить домен для продакшена
|
# todo потом установить домен для продакшена
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'shiftflow', # Вот это допиши обязательно!
|
'shiftflow', # Вот это допиши обязательно!
|
||||||
|
'warehouse',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ Including another URLconf
|
|||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
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 django.conf.urls.static import static # <--- Добавьте эту строку
|
||||||
from core import settings
|
from core import settings
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('favicon.ico', RedirectView.as_view(url=static_url('favicon.svg'), permanent=True)),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
# Добавь эту строку, она подключит login, logout и прочие стандартные пути
|
# Добавь эту строку, она подключит login, logout и прочие стандартные пути
|
||||||
path('accounts/', include('django.contrib.auth.urls')),
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(Company)
|
||||||
@@ -15,11 +15,6 @@ class DealAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('number', 'company__name')
|
search_fields = ('number', 'company__name')
|
||||||
list_filter = ('company',)
|
list_filter = ('company',)
|
||||||
|
|
||||||
# --- Настройка отображения Материалов ---
|
|
||||||
@admin.register(Material)
|
|
||||||
class MaterialAdmin(admin.ModelAdmin):
|
|
||||||
search_fields = ('name',)
|
|
||||||
|
|
||||||
# --- Задания на производство (База) ---
|
# --- Задания на производство (База) ---
|
||||||
@admin.register(ProductionTask)
|
@admin.register(ProductionTask)
|
||||||
class ProductionTaskAdmin(admin.ModelAdmin):
|
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 = 'Сделка'
|
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 = 'Деталь'
|
get_drawing.short_description = 'Деталь'
|
||||||
|
|
||||||
# Регистрация станков просто списком
|
# Регистрация станков просто списком
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Статус'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from warehouse.models import Material as WarehouseMaterial
|
||||||
|
|
||||||
class Company(models.Model):
|
class Company(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -38,17 +39,6 @@ class Deal(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Сделка"; verbose_name_plural = "Сделки"
|
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):
|
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)
|
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)
|
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("Заказано всего, шт")
|
quantity_ordered = models.PositiveIntegerField("Заказано всего, шт")
|
||||||
is_bend = models.BooleanField("Гибка", default=False)
|
is_bend = models.BooleanField("Гибка", default=False)
|
||||||
|
|
||||||
@@ -84,6 +74,7 @@ class Item(models.Model):
|
|||||||
('done', 'Выполнено'),
|
('done', 'Выполнено'),
|
||||||
('partial', 'Частично'),
|
('partial', 'Частично'),
|
||||||
('leftover', 'Недодел'),
|
('leftover', 'Недодел'),
|
||||||
|
('imported', 'Импортировано'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
|
# --- Ссылка на основу (временно null=True для миграции старых данных) ---
|
||||||
@@ -106,11 +97,13 @@ class Item(models.Model):
|
|||||||
is_synced_1c = models.BooleanField("Учтено в 1С", default=False)
|
is_synced_1c = models.BooleanField("Учтено в 1С", default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Позиция сменки"; verbose_name_plural = "Реестр сменных заданий"
|
verbose_name = "Пункт сменки"; verbose_name_plural = "Реестр сменных заданий"
|
||||||
ordering = ['-date', 'task__deal']
|
ordering = ['-date', 'task__deal']
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
class EmployeeProfile(models.Model):
|
||||||
|
|||||||
@@ -52,6 +52,21 @@
|
|||||||
<label class="small text-muted">Сколько сделано?</label>
|
<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 }}">
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
|
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
|
||||||
@@ -120,7 +135,7 @@ function closeTask(status) {
|
|||||||
|
|
||||||
function showPartial() {
|
function showPartial() {
|
||||||
document.getElementById('partialInput').classList.remove('d-none');
|
document.getElementById('partialInput').classList.remove('d-none');
|
||||||
document.getElementById('id_status').value = 'part'; // Статус Частично
|
document.getElementById('id_status').value = 'partial';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,45 +1,81 @@
|
|||||||
<div class="card border-secondary mb-3 shadow-sm">
|
<div class="card border-secondary mb-3 shadow-sm">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<form method="get" id="filter-form" class="row g-2 align-items-center">
|
<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="small text-muted mb-1 fw-bold">Станки:</div>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
{% for m in machines %}
|
{% for m in machines %}
|
||||||
<div>
|
<div>
|
||||||
<input type="checkbox" class="btn-check" name="m_ids" id="m_{{ m.id }}" value="{{ m.id }}"
|
<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>
|
<label class="btn btn-outline-accent btn-sm" for="m_{{ m.id }}">{{ m.name }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="small text-muted mb-1 fw-bold">Статус:</div>
|
<div class="small text-muted mb-1 fw-bold">Статус:</div>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<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()">
|
{% if user_role == 'operator' %}
|
||||||
<label class="btn btn-outline-primary btn-sm" for="s_work">В работе</label>
|
<input type="hidden" name="statuses" value="work">
|
||||||
|
<span class="badge bg-primary">В работе</span>
|
||||||
<input type="checkbox" class="btn-check" name="statuses" id="s_partial" value="partial" {% if 'partial' in selected_statuses %}checked{% endif %} onchange="this.form.submit()">
|
{% else %}
|
||||||
<label class="btn btn-outline-danger btn-sm" for="s_partial">Недодел</label>
|
<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()">
|
<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_done">Завершено</label>
|
<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>
|
||||||
</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">
|
<div class="col-md-2">
|
||||||
<label class="small text-muted mb-1 fw-bold">С:</label>
|
<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>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="small text-muted mb-1 fw-bold">По:</label>
|
<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>
|
||||||
|
|
||||||
<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="Сбросить по умолчанию"><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>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include 'shiftflow/partials/_filter.html' %}
|
||||||
|
|
||||||
<div class="card shadow border-secondary">
|
<div class="card shadow border-secondary">
|
||||||
<div class="card-header border-secondary py-3">
|
<div class="card-header border-secondary py-3">
|
||||||
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
|
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
|
||||||
@@ -27,23 +29,23 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr class="clickable-row" data-href="{% url 'item_detail' item.pk %}">
|
<tr class="clickable-row" data-href="{% url 'item_detail' item.pk %}">
|
||||||
<td class="small">{{ item.date|date:"d.m.y" }}</td>
|
<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><span class="badge bg-dark border border-secondary">{{ item.machine.name }}</span></td>
|
||||||
<td class="fw-bold">{{ item.drawing_name }}</td>
|
<td class="fw-bold">{{ item.task.drawing_name|default:"Б/ч" }}</td>
|
||||||
<td class="small">{{ item.size_value }}</td>
|
<td class="small">{{ item.task.size_value|default:"-" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="text-info fw-bold">{{ item.quantity_plan }}</span> /
|
<span class="text-info fw-bold">{{ item.quantity_plan }}</span> /
|
||||||
<span class="text-success">{{ item.quantity_fact }}</span>
|
<span class="text-success">{{ item.quantity_fact }}</span>
|
||||||
</td>
|
</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">
|
<td class="text-center">
|
||||||
{% if item.drawing_file %}
|
{% if item.task.drawing_file %}
|
||||||
<a href="{{ item.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
|
<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>
|
<i class="bi bi-file-earmark-code"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.extra_drawing %}
|
{% if item.task.extra_drawing %}
|
||||||
<a href="{{ item.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
|
<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>
|
<i class="bi bi-file-pdf"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from django.shortcuts import redirect
|
|||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views.generic import TemplateView, ListView, UpdateView
|
from django.views.generic import TemplateView, ListView, UpdateView
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from .models import Item # Проверь, как точно называется твоя модель деталей/заказов
|
from django.utils import timezone
|
||||||
|
from .models import Item, Machine
|
||||||
|
|
||||||
# Класс главной страницы (роутер)
|
# Класс главной страницы (роутер)
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
@@ -22,22 +23,84 @@ class RegistryView(LoginRequiredMixin, ListView):
|
|||||||
context_object_name = 'items'
|
context_object_name = 'items'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Оптимизируем запросы, подгружая связанные данные сразу
|
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine')
|
||||||
queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine').all()
|
user = self.request.user
|
||||||
|
profile = getattr(user, 'profile', None)
|
||||||
# Если это оператор, показываем только задания для его станков
|
role = profile.role if profile else 'operator'
|
||||||
if hasattr(self.request.user, 'profile') and self.request.user.profile.role == 'operator':
|
filtered = self.request.GET.get('filtered')
|
||||||
user_machines = self.request.user.profile.machines.all()
|
|
||||||
if user_machines.exists():
|
# Станки
|
||||||
queryset = queryset.filter(machine__in=user_machines)
|
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')
|
return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
# Передаем роль в шаблон, чтобы скрывать/показывать кнопки
|
user = self.request.user
|
||||||
if hasattr(self.request.user, 'profile'):
|
profile = getattr(user, 'profile', None)
|
||||||
context['user_role'] = self.request.user.profile.role
|
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
|
return context
|
||||||
|
|
||||||
# Вьюха детального вида и редактирования
|
# Вьюха детального вида и редактирования
|
||||||
@@ -59,6 +122,54 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
context['user_role'] = self.request.user.profile.role
|
context['user_role'] = self.request.user.profile.role
|
||||||
return context
|
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):
|
def get_success_url(self):
|
||||||
# После сохранения возвращаемся в реестр
|
|
||||||
return reverse_lazy('registry')
|
return reverse_lazy('registry')
|
||||||
@@ -20,7 +20,8 @@ body {
|
|||||||
.navbar .nav-link,
|
.navbar .nav-link,
|
||||||
.navbar .navbar-brand,
|
.navbar .navbar-brand,
|
||||||
.footer-custom span,
|
.footer-custom span,
|
||||||
.footer-custom strong {
|
.footer-custom strong,
|
||||||
|
.footer-custom .text-muted {
|
||||||
color: #e9ecef !important;
|
color: #e9ecef !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,11 @@ body {
|
|||||||
--bs-accent: #0d6efd; /* Синий акцент для светлой темы */
|
--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;
|
border-color: var(--bs-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Состояние кнопки при наведении */
|
.btn-check:checked + .btn-outline-accent,
|
||||||
.btn-outline-accent:hover {
|
.btn-outline-accent.active,
|
||||||
|
.btn-outline-accent:active {
|
||||||
background-color: var(--bs-accent) !important;
|
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
3
static/favicon.svg
Normal 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 |
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}ShiftFlow MES{% endblock %}</title>
|
<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 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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||||
|
|||||||
0
warehouse/__init__.py
Normal file
0
warehouse/__init__.py
Normal file
19
warehouse/admin.py
Normal file
19
warehouse/admin.py
Normal 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
6
warehouse/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class WarehouseConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'warehouse'
|
||||||
|
verbose_name = 'Склад и материалы'
|
||||||
53
warehouse/migrations/0001_initial.py
Normal file
53
warehouse/migrations/0001_initial.py
Normal 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': 'Материалы',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
warehouse/migrations/0002_materialcategory_gost_standard.py
Normal file
18
warehouse/migrations/0002_materialcategory_gost_standard.py
Normal 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='ГОСТ на тип проката'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
warehouse/migrations/__init__.py
Normal file
0
warehouse/migrations/__init__.py
Normal file
46
warehouse/models.py
Normal file
46
warehouse/models.py
Normal 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
0
warehouse/views.py
Normal file
Reference in New Issue
Block a user