From f759c2c17e20fb0a2156fb14a52c030a56252866 Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Sat, 28 Mar 2026 11:49:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8:=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=9A=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D0=93=D0=B0=D0=B1=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 23 ++- README.md | 63 +++++++- docker-compose.yml | 36 +++-- nginx/default.conf | 25 +++- ...ny_material_alter_item_options_and_more.py | 141 ++++++++++++++++++ shiftflow/models.py | 87 +++++++++-- 6 files changed, 337 insertions(+), 38 deletions(-) create mode 100644 shiftflow/migrations/0002_company_material_alter_item_options_and_more.py diff --git a/Dockerfile b/Dockerfile index 1f9b2d7..223ac85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,39 @@ +# Используем "легкую" версию Python на базе Debian Bookworm. +# slim — это баланс между размером образа и наличием нужных утилит. FROM python:3.12-slim +# Указываем рабочую папку внутри контейнера. Все последующие команды будут выполняться в ней. WORKDIR /app -# Окружение +# Настройки окружения: +# 1. Запрещаем Python писать файлы .pyc (байткод) на диск, чтобы не мусорить. ENV PYTHONDONTWRITEBYTECODE=1 +# 2. Отключаем буферизацию логов. Так ты сразу увидишь ошибки в `docker logs`, а не будешь их ждать. ENV PYTHONUNBUFFERED=1 -# Ставим системные либы для Postgres +# Ставим системные зависимости: +# apt-get update — обновляем списки пакетов. +# gcc и libpq-dev — необходимы для сборки библиотеки psycopg2 (драйвер для Postgres). +# rm -rf /var/lib/apt/lists/* — удаляем кэш установщика, чтобы уменьшить размер образа. RUN apt-get update && apt-get install -y --no-install-recommends \ gcc libpq-dev && rm -rf /var/lib/apt/lists/* +# Сначала копируем только список зависимостей. +# Это нужно для "кэширования слоев": если ты не менял библиотеки, Docker не будет переустанавливать их заново при сборке. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Теперь копируем весь остальной код проекта в контейнер. COPY . . -# Обязательно даем права скрипту +# Даем права на выполнение нашему скрипту запуска. +# Без этого контейнер может упасть с ошибкой "Permission denied". RUN chmod +x /app/entrypoint.sh -# Используем путь из рабочей папки +# ENTRYPOINT — это команда, которая выполняется ВСЕГДА при старте. +# Наш скрипт подготовит базу (миграции) и соберет статику. ENTRYPOINT ["/app/entrypoint.sh"] +# CMD — это основная команда процесса. +# Запускаем Gunicorn, привязываем его к порту 8000 и ставим 3 рабочих процесса для скорости. CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] \ No newline at end of file diff --git a/README.md b/README.md index 6aaead8..d088e84 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,64 @@ # mes_core -Система управления производством \ No newline at end of file +Система управления производством (ShiftFlow MES) + +## 📥 Как пушить код (Шпаргалка) + +Если внес изменения в проект и готов отправить их на сервер `ProdServ`: + +1. **Подготовь файлы**: + `git add .` +2. **Запечатай изменения**: + `git commit -m "Подправил докерфайл сборки контейнера"` +3. **Отправляй в полет**: + `git push origin main` + +### 💊 Таблетка: Если не пушится (сброс авторизации) +Если Git "забыл" пароль или выдает ошибку Permission Denied: + +```bash +# Очищаем старые привязки +git config --global --unset credential.helper + +# Устанавливаем менеджер заново (для Windows) +git config --global credential.helper manager + +# Снова пушим и вводим логин/пароль в окне +git push origin main + +# 🚀 ShiftFlow MES - Инструкция по деплою (Production) + +### 🧩 Общая архитектура +Система работает в связке из 3-х контейнеров: +1. **db**: PostgreSQL 15 (хранит все данные). +2. **web**: Django + Gunicorn (логика приложения). +3. **nginx**: Веб-сервер (раздает статику и проксирует запросы на Django). + +--- + +### 🛠 Как это работает (Автоматизация) +Мы используем **CI/CD через Gitea Actions**. Тебе не нужно заходить на сервер руками. + +1. **Push в main**: Как только ты пушишь код, раннер на `ProdServ` просыпается. +2. **Сборка**: Docker пересобирает образ `web`, если изменились `requirements.txt` или код. +3. **Запуск**: + - `entrypoint.sh` автоматически накатывает миграции (`migrate`). + - `collectstatic` собирает все стили в общую папку для Nginx. + - Создается суперпользователь (если его еще нет) из данных `.env`. + +--- + +### 📁 Важные папки на сервере `ProdServ` +Проект живет здесь: `/home/ack/projects/mes_core/` + +* **staticfiles/** — здесь лежат стили и скрипты. Если админка стала "белой", проверь права этой папки (`chmod 755`). +* **media/** — здесь будут лежать загруженные фото и файлы. +* **.env** — здесь лежат все пароли. **Никогда не удаляй его!** + +--- + +### 🆘 Что делать, если "все упало"? +Если сайт по адресу `192.168.1.108` не открывается: +1. Проверь логи контейнеров: `docker compose logs -f`. +2. Убедись, что порты в `docker-compose.yml` стоят `80:80`. +3. Перезапусти всё одной командой: `docker compose up -d --build`. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b196e2e..389b71b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,52 @@ -name: prodman +name: prodman # Имя проекта, которое будет префиксом для всех контейнеров и сетей + services: + # --- БАЗА ДАННЫХ --- db: - image: postgres:15-alpine - restart: unless-stopped + image: postgres:15-alpine # Легкий образ Postgres на базе Alpine Linux + restart: unless-stopped # Перезапускать всегда, кроме случаев, когда ты сам его выключил environment: + # Данные тянутся из твоего файла .env - POSTGRES_DB=${DB_NAME} - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASS} volumes: + # Храним базу в именованном томе, чтобы данные не пропали при удалении контейнера - postgres_data:/var/lib/postgresql/data + # --- ПРИЛОЖЕНИЕ (DJANGO) --- web: - build: . + build: . # Собирает образ из Dockerfile в текущей папке restart: unless-stopped env_file: - - .env + - .env # Прокидывает все секреты и настройки внутрь Python volumes: + # Общие папки для статики и картинок. Сюда Django их складывает. - staticfiles:/app/staticfiles - mediafiles:/app/media expose: - - "8000" + - "8000" # Открывает порт ТОЛЬКО внутри сети Docker для Nginx depends_on: - - db + - db # Сначала запустится база, потом приложение + # --- ВЕБ-СЕРВЕР (ФАСАД) --- nginx: image: nginx:1.25-alpine restart: unless-stopped volumes: - # Подключаем конфиг и статику с медиа в режиме только для чтения (т.к. nginx смотрит в интернет и может быть подвергнута атаке) + # Основной конфиг маршрутизации - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + # Читаем статику и медиа, которые подготовил контейнер 'web' + # :ro (read-only) — защита: даже если Nginx взломают, файлы не удалят - staticfiles:/app/staticfiles:ro - mediafiles:/app/media:ro ports: - - "80:80" + - "80:80" # Единственная "дырка" в мир: порт 80 сервера -> порт 80 контейнера depends_on: - - web + - web # Nginx запустится только после Django +# Описание "жестких дисков" (Volumes), которые живут дольше контейнеров volumes: - postgres_data: - staticfiles: - mediafiles: \ No newline at end of file + postgres_data: # Для данных БД + staticfiles: # Для CSS, JS и картинок интерфейса (collectstatic) + mediafiles: # Для загруженных тобой чертежей и фото \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf index 118cf90..1c79295 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -1,39 +1,54 @@ +# Описываем группу серверов, куда Nginx будет перекидывать запросы. +# 'web' — это имя сервиса из нашего docker-compose.yml. upstream django_app { server web:8000; } server { + # Слушаем стандартный 80-й порт (HTTP). listen 80; - # Добавляем конкретный домен и IP сервера + + # Список имен, на которые будет откликаться сервер. + # Если зайти по другому IP, Nginx может выдать ошибку. server_name shiftflow.tertelius.space 192.168.1.108 localhost; - # Максимальный размер загружаемого файла + # Увеличиваем лимит загрузки (по умолчанию в Nginx всего 1МБ). + # 100М — чтобы ты мог спокойно грузить тяжелые чертежи или фото станков. client_max_body_size 100M; - # Сжатие (Gzip) — ускорит загрузку интерфейса + # Включаем Gzip-сжатие. Nginx будет сжимать текстовые файлы перед отправкой, + # что ускорит загрузку интерфейса, особенно на слабом интернете. gzip on; gzip_types text/plain text/css application/json application/javascript text/xml; + # Главный блок: всё, что не статика, летит в Django. location / { proxy_pass http://django_app; - # Передаем оригинальный хост (важно для ALLOWED_HOSTS) + # Передаем оригинальный домен/IP (нужно для ALLOWED_HOSTS в Django). proxy_set_header Host $host; + # Передаем реальный IP пользователя (чтобы в логах видеть, кто зашел). proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # КРИТИЧНО для CSRF защиты в Django + # Передаем протокол (http или https). + # КРИТИЧНО для CSRF защиты, чтобы Django не ругался при входе в админку. proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } + # Раздача статики (CSS, JS, картинки интерфейса). + # Nginx сам лезет в папку, не беспокоя Django — это очень быстро. location /static/ { + # Путь ВНУТРИ контейнера Nginx (куда мы примонтировали волюм). alias /app/staticfiles/; + # Заставляем браузер кэшировать стили на 30 дней, чтобы не качать их каждый раз. expires 30d; add_header Cache-Control "public, no-transform"; } + # Раздача медиа-файлов (чертежи, фото продукции). location /media/ { alias /app/media/; expires 30d; diff --git a/shiftflow/migrations/0002_company_material_alter_item_options_and_more.py b/shiftflow/migrations/0002_company_material_alter_item_options_and_more.py new file mode 100644 index 0000000..fe0103d --- /dev/null +++ b/shiftflow/migrations/0002_company_material_alter_item_options_and_more.py @@ -0,0 +1,141 @@ +# Generated by Django 6.0.3 on 2026-03-28 08:48 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shiftflow', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Company', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='Название компании')), + ('description', models.TextField(blank=True, verbose_name='Краткое описание / Примечание')), + ], + 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, unique=True, verbose_name='Наименование')), + ], + options={ + 'verbose_name': 'Материал', + 'verbose_name_plural': 'Материалы', + }, + ), + migrations.AlterModelOptions( + name='item', + options={'ordering': ['-date', 'deal'], 'verbose_name': 'Позиция', 'verbose_name_plural': 'Сменное задание'}, + ), + migrations.RemoveField( + model_name='item', + name='dim_value', + ), + migrations.RemoveField( + model_name='item', + name='priority', + ), + migrations.AddField( + model_name='item', + name='drawing_file', + field=models.FileField(blank=True, null=True, upload_to='drawings/%Y/%m/', verbose_name='Исходник (DXF/STEP)'), + ), + migrations.AddField( + model_name='item', + name='extra_drawing', + field=models.FileField(blank=True, null=True, upload_to='extra_drawings/%Y/%m/', verbose_name='Доп. чертеж (PDF)'), + ), + migrations.AddField( + model_name='item', + name='is_bend', + field=models.BooleanField(default=False, verbose_name='Гибка'), + ), + migrations.AddField( + model_name='item', + name='is_synced_1c', + field=models.BooleanField(default=False, verbose_name='Учтено в 1С'), + ), + migrations.AddField( + model_name='item', + name='material_taken', + field=models.TextField(blank=True, help_text='Напр: 3 трубы по 12м', verbose_name='Взятый материал'), + ), + migrations.AddField( + model_name='item', + name='operator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель'), + ), + migrations.AddField( + model_name='item', + name='scrap_weight', + field=models.FloatField(default=0.0, verbose_name='Лом (кг)'), + ), + migrations.AddField( + model_name='item', + name='size_value', + field=models.FloatField(default=0, help_text='Длина (мм) или Толщина (мм)', verbose_name='Размер детали'), + preserve_default=False, + ), + migrations.AddField( + model_name='item', + name='usable_waste', + field=models.TextField(blank=True, help_text='Напр: кусок 1500мм', verbose_name='Деловой отход'), + ), + migrations.AlterField( + model_name='item', + name='date', + field=models.DateField(default=django.utils.timezone.now, verbose_name='Дата задания'), + ), + migrations.AlterField( + model_name='item', + name='drawing_name', + field=models.CharField(blank=True, default='Б/ч', max_length=255, verbose_name='Название детали'), + ), + migrations.AlterField( + model_name='item', + name='status', + field=models.CharField(choices=[('work', 'В работе'), ('done', 'Выполнено'), ('partial', 'Частично'), ('leftover', 'Недодел')], default='work', max_length=10, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='machine', + name='name', + field=models.CharField(max_length=100, verbose_name='Название станка'), + ), + migrations.CreateModel( + name='Deal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.CharField(max_length=100, unique=True, verbose_name='№ Сделки')), + ('description', models.TextField(blank=True, help_text='Общая информация по заказу', verbose_name='Описание сделки')), + ('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.company', verbose_name='Заказчик')), + ], + options={ + 'verbose_name': 'Сделка', + 'verbose_name_plural': 'Сделки', + }, + ), + migrations.AlterField( + model_name='item', + name='deal', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка'), + ), + migrations.AlterField( + model_name='item', + name='material', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.material', verbose_name='Материал'), + ), + ] diff --git a/shiftflow/models.py b/shiftflow/models.py index e12180b..9fe45c5 100644 --- a/shiftflow/models.py +++ b/shiftflow/models.py @@ -1,40 +1,97 @@ from django.db import models - -# Create your models here. -from django.db import models from django.utils import timezone +from django.contrib.auth.models import User + +class Company(models.Model): + """ + Справочник контрагентов/заказчиков. + Позволяет группировать сделки по компаниям и избегать дублей в названиях. + """ + name = models.CharField("Название компании", max_length=255, unique=True) + description = models.TextField("Краткое описание / Примечание", blank=True) + + def __str__(self): return self.name + class Meta: + verbose_name = "Компания"; verbose_name_plural = "Компании" class Machine(models.Model): - name = models.CharField("Станок", max_length=100) # Лентопил, Труборез, Лазер + """ + Список производственных участков (станков). + Используется для фильтрации сменных заданий для конкретных операторов. + """ + name = models.CharField("Название станка", max_length=100) + def __str__(self): return self.name class Meta: verbose_name = "Станок"; verbose_name_plural = "Станки" +class Deal(models.Model): + """ + Заказ или проект. Номер парсится из пути к файлам. + Служит контейнером для группы деталей (позиций). + """ + number = models.CharField("№ Сделки", max_length=100, unique=True) + company = models.ForeignKey(Company, on_delete=models.PROTECT, verbose_name="Заказчик", null=True, blank=True) + description = models.TextField("Описание сделки", blank=True, help_text="Общая информация по заказу") + + def __str__(self): return f"Сделка №{self.number} ({self.company})" + 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 Item(models.Model): + """ + Единица сменного задания. Основная рабочая сущность. + Статус по умолчанию 'work' (В работе). + """ STATUS_CHOICES = [ - ('new', 'В задании'), ('work', 'В работе'), ('done', 'Выполнено'), + ('partial', 'Частично'), + ('leftover', 'Недодел'), ] - date = models.DateField("Дата", default=timezone.now) + # --- База (заполняет начальник) --- + date = models.DateField("Дата задания", default=timezone.now) machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name="Станок") - deal = models.CharField("№ Сделки", max_length=100) # Твои "Переходники" или заказы - drawing_name = models.CharField("Чертеж / Деталь", max_length=255) + deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка") - # Характеристики из твоих файлов - material = models.CharField("Материал", max_length=255) # Труба 180х32, MS 12.00mm и т.д. - dim_value = models.FloatField("Размер (мм)", help_text="Длина реза или толщина листа") + drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч") + size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)") + drawing_file = models.FileField("Исходник (DXF/STEP)", 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="Материал") quantity_plan = models.PositiveIntegerField("План, шт") + is_bend = models.BooleanField("Гибка", default=False) + + # --- Исполнение (заполняет оператор/мастер) --- + operator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Исполнитель") quantity_fact = models.PositiveIntegerField("Факт, шт", default=0) - priority = models.PositiveIntegerField("Приоритет", default=10) - status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='new') + material_taken = models.TextField("Взятый материал", blank=True, help_text="Напр: 3 трубы по 12м") + usable_waste = models.TextField("Деловой отход", blank=True, help_text="Напр: кусок 1500мм") + scrap_weight = models.FloatField("Лом (кг)", default=0.0) + # --- Статусы и учет --- + status = models.CharField("Статус", max_length=10, choices=STATUS_CHOICES, default='work') + is_synced_1c = models.BooleanField("Учтено в 1С", default=False) + class Meta: verbose_name = "Позиция"; verbose_name_plural = "Сменное задание" - ordering = ['-date', 'priority'] + ordering = ['-date', 'deal'] def __str__(self): - return f"{self.drawing_name} ({self.deal})" \ No newline at end of file + return f"{self.drawing_name} ({self.quantity_plan} шт.)" + \ No newline at end of file