This commit is contained in:
xds
2026-03-22 12:40:33 +03:00
commit 28a5d51389
61 changed files with 6085 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
DB_PASSWORD=print3d_secret
GOOGLE_API_KEY=
TELEGRAM_BOT_TOKEN=8730332716:AAFOZeGhWL99-3_s11cWo2GrpeUAMb6Qkw0
TELEGRAM_CHAT_ID=567047
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=filam3d
MINIO_SECURE=false

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.env
__pycache__/
*.pyc
node_modules/
dist/
.vite/
uploads/
*.egg-info/
.DS_Store

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

787
CLAUDE.md Normal file
View File

@@ -0,0 +1,787 @@
# 3D Print Calculator — Техническое задание на MVP
## Обзор проекта
Сервис 3D-печати на заказ с автоматическим расчётом стоимости. Клиент загружает 3D-модель (STL, 3MF, OBJ), выбирает материал, получает мгновенный расчёт цены и оформляет заказ. AI-ассистент помогает выбрать оптимальный материал под задачу.
**Бизнес-модель:** B2B — корпуса для электроники, функциональные запчасти, прототипы.
**Стек:** Python (FastAPI) + Vue 3 (Vite) + PostgreSQL + Nginx + Docker Compose.
**Хостинг:** VPS (Ubuntu 24), деплой через Docker Compose.
---
## Архитектура
```
┌─────────────────────────────────────────────┐
│ Nginx (reverse proxy) │
│ :80 → frontend, /api → backend │
└──────────┬──────────────┬───────────────────┘
│ │
┌──────────▼──────┐ ┌─────▼──────────────────┐
│ Vue 3 (Vite) │ │ FastAPI (Python) │
│ SPA, static │ │ REST API + WebSocket │
│ Port: 5173 │ │ Port: 8000 │
└─────────────────┘ └──────┬─────┬────────────┘
│ │
┌──────▼┐ ┌──▼───────────┐
│ PostgreSQL │ File Storage │
│ :5432 │ (local /uploads) │
└───────┘ └──────────────┘
```
### Контейнеры Docker Compose
1. **frontend** — Node 20 + Vite dev server (в проде — собранная статика через Nginx)
2. **backend** — Python 3.12 + FastAPI + Uvicorn
3. **db** — PostgreSQL 16
4. **nginx** — reverse proxy, SSL termination
---
## Backend (FastAPI)
### Структура проекта
```
backend/
├── app/
│ ├── main.py # FastAPI app, CORS, middleware
│ ├── config.py # Settings (pydantic-settings)
│ ├── database.py # SQLAlchemy async engine + session
│ ├── models/
│ │ ├── material.py # Material ORM model
│ │ ├── order.py # Order ORM model
│ │ └── file_upload.py # Uploaded file metadata
│ ├── schemas/
│ │ ├── calculate.py # Request/Response для калькулятора
│ │ ├── material.py # Material schemas
│ │ └── order.py # Order schemas
│ ├── routers/
│ │ ├── calculate.py # POST /api/calculate
│ │ ├── materials.py # GET /api/materials
│ │ ├── orders.py # POST /api/orders
│ │ └── ai_advisor.py # POST /api/advisor
│ ├── services/
│ │ ├── file_parser.py # Парсинг STL/3MF/OBJ → геометрия
│ │ ├── price_engine.py # Расчёт стоимости
│ │ ├── ai_advisor.py # Интеграция с Claude API
│ │ └── telegram_notify.py # Уведомления в Telegram
│ └── seed/
│ └── materials.py # Начальные данные по материалам
├── requirements.txt
├── Dockerfile
└── alembic/ # Миграции БД
```
### API Endpoints
#### 1. POST /api/calculate
Основной endpoint калькулятора. Принимает файл и параметры, возвращает расчёт.
**Request:** `multipart/form-data`
- `file` (File, required) — 3D-модель (STL, 3MF, OBJ). Макс. размер: 50MB.
- `material_id` (int, required) — ID выбранного материала
- `infill_percent` (int, optional, default=30) — Процент заполнения (10-100)
- `layer_height_mm` (float, optional, default=0.2) — Высота слоя (0.08-0.4)
- `quantity` (int, optional, default=1) — Количество экземпляров (1-500)
- `post_processing` (str[], optional) — Постобработка: ["sanding", "painting", "threading"]
**Response:** `application/json`
```json
{
"success": true,
"file_info": {
"filename": "case_v3.stl",
"format": "stl",
"volume_cm3": 42.7,
"surface_area_cm2": 198.3,
"bounding_box_mm": {"x": 120.0, "y": 80.0, "z": 35.0},
"is_watertight": true,
"triangle_count": 12840
},
"calculation": {
"material": {
"id": 3,
"name": "PA (Nylon)",
"density_g_cm3": 1.14,
"price_per_gram": 50.0
},
"weight_grams": 48.7,
"material_cost_rub": 2435.0,
"print_time_hours": 4.2,
"time_cost_rub": 840.0,
"post_processing_cost_rub": 500.0,
"subtotal_rub": 3775.0,
"quantity": 2,
"quantity_discount_percent": 5,
"total_rub": 7172.5,
"estimated_days": 3
}
}
```
**Ошибки:**
- 400 — Неподдерживаемый формат файла
- 400 — Файл повреждён или не является 3D-моделью
- 400 — Модель не является водонепроницаемой (warning, не blocking)
- 413 — Файл слишком большой (>50MB)
#### 2. GET /api/materials
Список доступных материалов с характеристиками.
**Response:**
```json
[
{
"id": 1,
"name": "PLA",
"category": "basic",
"price_per_gram": 25.0,
"density_g_cm3": 1.24,
"properties": {
"max_temp_c": 60,
"strength": "medium",
"flexibility": "low",
"chemical_resistance": "low",
"food_safe": true
},
"description": "Базовый пластик, подходит для прототипов и декоративных изделий",
"color_options": ["white", "black", "gray", "red", "blue", "green", "natural"]
}
]
```
#### 3. POST /api/advisor
AI-ассистент для выбора материала. Принимает описание задачи, возвращает рекомендацию.
**Request:**
```json
{
"task_description": "Нужен корпус для уличного датчика температуры. Будет стоять на улице, диапазон температур от -30 до +50. Должен быть водонепроницаемым.",
"budget_preference": "optimal",
"file_info": {
"volume_cm3": 42.7,
"bounding_box_mm": {"x": 120, "y": 80, "z": 35}
}
}
```
**Response:**
```json
{
"recommended_material_id": 4,
"recommended_material_name": "PETG",
"reasoning": "Для уличного корпуса датчика рекомендую PETG: термостойкость до +80°C, морозостойкость до -40°C, хорошая UV-стойкость и водонепроницаемость. ABS тоже подошёл бы, но PETG проще в печати и не требует закрытой камеры.",
"alternatives": [
{
"material_id": 2,
"name": "ABS",
"why": "Выше ударопрочность, но требует постобработки для герметичности"
},
{
"material_id": 5,
"name": "ASA",
"why": "Лучшая UV-стойкость, но дороже"
}
]
}
```
**Реализация:** Отправляем запрос к Claude API (модель claude-sonnet-4-20250514) с системным промптом, содержащим каталог материалов и их свойства. Ключ API хранится в переменной окружения `ANTHROPIC_API_KEY`.
#### 4. POST /api/orders
Оформление заказа.
**Request:**
```json
{
"calculation_id": "uuid-of-saved-calculation",
"client_name": "Иван Петров",
"client_phone": "+79001234567",
"client_email": "ivan@example.com",
"client_company": "ООО Технопарк",
"delivery_method": "pickup",
"comment": "Нужно к пятнице, нанести резьбу M4 в двух отверстиях"
}
```
**Response:**
```json
{
"order_id": "ORD-2026-0042",
"status": "pending",
"total_rub": 7172.5,
"estimated_ready_date": "2026-04-02"
}
```
**Side effect:** Отправляет уведомление в Telegram-бот владельца с деталями заказа.
### Сервис парсинга файлов (file_parser.py)
Используемые библиотеки:
- `trimesh` — основной парсер. Читает STL (binary + ASCII), OBJ, 3MF, PLY, GLTF.
- `numpy-stl` — запасной вариант для STL, если trimesh не справился.
Что извлекаем из файла:
```python
import trimesh
def parse_3d_file(file_path: str, file_extension: str) -> FileInfo:
"""
Парсит 3D-файл и возвращает геометрические характеристики.
Поддерживаемые форматы: .stl, .3mf, .obj
STEP-файлы (.step, .stp) — в v2 (требует cadquery/OCP).
"""
mesh = trimesh.load(file_path, file_type=file_extension)
# Если 3MF содержит несколько тел — объединяем
if isinstance(mesh, trimesh.Scene):
mesh = trimesh.util.concatenate(mesh.dump())
return FileInfo(
volume_cm3=mesh.volume / 1000, # mm³ → cm³
surface_area_cm2=mesh.area / 100, # mm² → cm²
bounding_box_mm={
"x": mesh.bounding_box.extents[0],
"y": mesh.bounding_box.extents[1],
"z": mesh.bounding_box.extents[2],
},
is_watertight=mesh.is_watertight,
triangle_count=len(mesh.faces),
)
```
### Сервис расчёта цены (price_engine.py)
```python
def calculate_price(
file_info: FileInfo,
material: Material,
infill_percent: int = 30,
layer_height_mm: float = 0.2,
quantity: int = 1,
post_processing: list[str] = [],
) -> Calculation:
"""
Формула:
1. effective_volume = volume_cm3 * (infill_percent / 100) * 0.7 + volume_cm3 * 0.3
(70% объёма масштабируется по infill, 30% — стенки, всегда 100%)
2. weight_g = effective_volume * material.density_g_cm3
3. material_cost = weight_g * material.price_per_gram
4. print_time_h = estimate_print_time(file_info, layer_height_mm, material)
5. time_cost = print_time_h * TIME_RATE_PER_HOUR # ~200 руб/час
6. post_processing_cost = sum стоимостей выбранных операций
7. subtotal = material_cost + time_cost + post_processing_cost
8. total = subtotal * quantity * (1 - volume_discount(quantity))
"""
```
**Оценка времени печати (упрощённая):**
```python
def estimate_print_time(file_info, layer_height_mm, material):
"""
Упрощённая оценка без полного слайсинга.
layers = bounding_box_z / layer_height
volume_per_layer = volume_cm3 / layers * 1000 # mm³
time_per_layer = volume_per_layer / material.flow_rate_mm3_s / 60 # минуты
travel_time_per_layer ≈ 0.3 мин (константа для Bambu Lab)
total = layers * (time_per_layer + travel_time) + setup_time
"""
```
**Скидки за количество:**
- 1 шт — 0%
- 2-5 шт — 5%
- 6-20 шт — 10%
- 21-100 шт — 15%
- 101-500 шт — 20%
**Стоимость постобработки:**
- `sanding` (шлифовка) — 300 руб/шт
- `painting` (покраска) — 500 руб/шт
- `threading` (нарезка резьбы) — 200 руб/отверстие
- `acetone_smoothing` (ацетоновая обработка, только ABS) — 400 руб/шт
### Справочник материалов (seed data)
```python
MATERIALS = [
{
"name": "PLA",
"category": "basic",
"density_g_cm3": 1.24,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 15.0,
"max_temp_c": 60,
"min_temp_c": -20,
"strength": "medium",
"flexibility": "low",
"chemical_resistance": "low",
"uv_resistance": "low",
"food_safe": True,
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.",
},
{
"name": "PETG",
"category": "basic",
"density_g_cm3": 1.27,
"price_per_gram": 28.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 80,
"min_temp_c": -40,
"strength": "high",
"flexibility": "medium",
"chemical_resistance": "medium",
"uv_resistance": "medium",
"food_safe": True,
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.",
},
{
"name": "ABS",
"category": "basic",
"density_g_cm3": 1.04,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 100,
"min_temp_c": -30,
"strength": "high",
"flexibility": "low",
"chemical_resistance": "medium",
"uv_resistance": "low",
"food_safe": False,
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.",
},
{
"name": "PA (Nylon)",
"category": "engineering",
"density_g_cm3": 1.14,
"price_per_gram": 50.0,
"flow_rate_mm3_s": 10.0,
"max_temp_c": 120,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "medium",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
},
{
"name": "PC (Поликарбонат)",
"category": "engineering",
"density_g_cm3": 1.20,
"price_per_gram": 60.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 140,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "low",
"chemical_resistance": "high",
"uv_resistance": "high",
"food_safe": False,
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
},
{
"name": "TPU",
"category": "engineering",
"density_g_cm3": 1.21,
"price_per_gram": 40.0,
"flow_rate_mm3_s": 6.0,
"max_temp_c": 80,
"min_temp_c": -30,
"strength": "medium",
"flexibility": "very_high",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
},
{
"name": "PA-CF (Нейлон + углеволокно)",
"category": "composite",
"density_g_cm3": 1.18,
"price_per_gram": 75.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 150,
"min_temp_c": -40,
"strength": "extreme",
"flexibility": "low",
"chemical_resistance": "very_high",
"uv_resistance": "high",
"food_safe": False,
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
},
]
```
### AI Advisor — системный промпт
Файл `app/services/ai_advisor.py` использует Anthropic Python SDK:
```python
SYSTEM_PROMPT = """
Ты — эксперт по 3D-печати из инженерных пластиков по технологии FDM.
Твоя задача — рекомендовать оптимальный материал для печати на основе описания задачи клиента.
Доступные материалы:
{materials_json}
Правила:
1. Всегда рекомендуй один основной материал и 1-2 альтернативы.
2. Учитывай: температурный режим, механические нагрузки, химическое воздействие, UV, влажность.
3. Если задача не подходит для FDM-печати (слишком мелкие детали, высокая точность) — честно скажи об этом.
4. Отвечай кратко, по делу, на русском языке.
5. Если клиент не указал критичные параметры — задай уточняющие вопросы.
Формат ответа — строго JSON:
{
"recommended_material_id": <int>,
"reasoning": "<обоснование на русском>",
"alternatives": [{"material_id": <int>, "name": "<str>", "why": "<причина>"}],
"questions": ["<вопрос, если нужна доп. информация>"] // пустой массив если вопросов нет
}
"""
```
### Telegram-уведомления (telegram_notify.py)
При создании заказа отправляем сообщение в Telegram-бот владельца:
```python
import httpx
async def notify_new_order(order: Order):
"""Отправляет уведомление о новом заказе в Telegram."""
text = (
f"🆕 Новый заказ #{order.order_id}\n"
f"Клиент: {order.client_name}\n"
f"Телефон: {order.client_phone}\n"
f"Материал: {order.material_name}\n"
f"Сумма: {order.total_rub}\n"
f"Комментарий: {order.comment or ''}"
)
await httpx.AsyncClient().post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={"chat_id": TELEGRAM_CHAT_ID, "text": text}
)
```
Переменные окружения: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`.
---
## Frontend (Vue 3 + Vite)
### Структура проекта
```
frontend/
├── src/
│ ├── App.vue
│ ├── main.js
│ ├── router/
│ │ └── index.js # Vue Router
│ ├── stores/
│ │ ├── calculator.js # Pinia store — состояние калькулятора
│ │ └── materials.js # Pinia store — материалы
│ ├── api/
│ │ └── client.js # Axios instance, базовые запросы
│ ├── views/
│ │ ├── CalculatorView.vue # Главная страница с калькулятором
│ │ ├── MaterialsView.vue # Каталог материалов
│ │ └── OrderView.vue # Форма заказа
│ ├── components/
│ │ ├── FileUploader.vue # Drag-and-drop загрузка файла
│ │ ├── MaterialPicker.vue # Выбор материала (карточки)
│ │ ├── PrintSettings.vue # Настройки печати (infill, layer)
│ │ ├── PriceResult.vue # Отображение расчёта
│ │ ├── AiAdvisor.vue # Чат с AI-ассистентом
│ │ └── OrderForm.vue # Форма заказа
│ └── assets/
│ └── styles/
│ └── main.css # Tailwind CSS
├── index.html
├── vite.config.js
├── tailwind.config.js
├── package.json
└── Dockerfile
```
### Зависимости
```json
{
"dependencies": {
"vue": "^3.5",
"vue-router": "^4.4",
"pinia": "^2.2",
"axios": "^1.7"
},
"devDependencies": {
"vite": "^6.0",
"@vitejs/plugin-vue": "^5.1",
"tailwindcss": "^3.4",
"autoprefixer": "^10.4",
"postcss": "^8.4"
}
}
```
### Страницы и маршруты
| Путь | Компонент | Описание |
|------|-----------|----------|
| `/` | CalculatorView | Главная: загрузка файла → материал → настройки → цена |
| `/materials` | MaterialsView | Каталог материалов с фильтрами |
| `/order/:calcId` | OrderView | Форма оформления заказа |
### Компонент FileUploader.vue
Требования:
- Drag-and-drop зона + кнопка «Выбрать файл»
- Принимает: `.stl`, `.3mf`, `.obj` (валидация по расширению на фронте)
- Максимальный размер: 50 MB
- Отображает имя файла, размер и иконку формата после загрузки
- Показывает прогресс-бар при отправке на сервер
- При ошибке парсинга — человекочитаемое сообщение
### Компонент MaterialPicker.vue
Требования:
- Отображает материалы карточками (не dropdown)
- Карточка содержит: название, цену за грамм, ключевые свойства (иконки)
- Разделение на категории: «Базовые», «Инженерные», «Композитные»
- Выбранный материал подсвечивается
- Кнопка «Помочь выбрать» открывает AI-ассистент
### Компонент PrintSettings.vue
Требования:
- Слайдер: заполнение (10%-100%, шаг 10%, default 30%)
- Слайдер: высота слоя (0.08-0.4mm, шаг 0.04, default 0.2)
- Поле: количество (1-500, default 1)
- Чекбоксы: постобработка (шлифовка, покраска, резьба, ацетон)
- Подсказки при наведении: как параметр влияет на результат
### Компонент PriceResult.vue
Требования:
- Показывает разбивку: материал, время, постобработка, скидка, итого
- Крупно отображает итоговую цену
- Показывает примерный срок изготовления
- Кнопка «Оформить заказ» → переход на /order/:calcId
- Кнопка «Скачать расчёт (PDF)» — v2, пока не реализуем
### Компонент AiAdvisor.vue
Требования:
- Модальное окно или выдвижная панель справа
- Текстовое поле для описания задачи
- Кнопка «Получить рекомендацию»
- Отображает рекомендацию: основной материал + альтернативы с обоснованием
- Кнопка «Выбрать» рядом с каждой рекомендацией — применяет материал в калькулятор
### Дизайн
- Стиль: минималистичный, светлая тема, Tailwind CSS
- Акцентный цвет: #2563EB (синий) — кнопки, выделения
- Шрифт: Inter (Google Fonts)
- Адаптивность: mobile-first, работает на телефоне
- Тёмная тема: v2 (не в MVP)
---
## База данных (PostgreSQL)
### Таблицы
**materials**
```sql
CREATE TABLE materials (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL, -- basic, engineering, composite
density_g_cm3 FLOAT NOT NULL,
price_per_gram FLOAT NOT NULL,
flow_rate_mm3_s FLOAT NOT NULL,
max_temp_c INT,
min_temp_c INT,
strength VARCHAR(20), -- low, medium, high, very_high, extreme
flexibility VARCHAR(20),
chemical_resistance VARCHAR(20),
uv_resistance VARCHAR(20),
food_safe BOOLEAN DEFAULT FALSE,
description TEXT,
color_options JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
```
**calculations**
```sql
CREATE TABLE calculations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_name VARCHAR(255) NOT NULL,
file_format VARCHAR(10) NOT NULL,
file_path VARCHAR(500),
volume_cm3 FLOAT NOT NULL,
surface_area_cm2 FLOAT,
bounding_box JSONB,
is_watertight BOOLEAN,
triangle_count INT,
material_id INT REFERENCES materials(id),
infill_percent INT DEFAULT 30,
layer_height_mm FLOAT DEFAULT 0.2,
quantity INT DEFAULT 1,
post_processing JSONB DEFAULT '[]',
weight_grams FLOAT,
material_cost_rub FLOAT,
time_cost_rub FLOAT,
post_processing_cost_rub FLOAT,
total_rub FLOAT,
estimated_days INT,
created_at TIMESTAMP DEFAULT NOW()
);
```
**orders**
```sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
order_id VARCHAR(20) UNIQUE NOT NULL, -- ORD-2026-0001
calculation_id UUID REFERENCES calculations(id),
client_name VARCHAR(200) NOT NULL,
client_phone VARCHAR(20) NOT NULL,
client_email VARCHAR(200),
client_company VARCHAR(200),
delivery_method VARCHAR(50) DEFAULT 'pickup', -- pickup, delivery
comment TEXT,
status VARCHAR(30) DEFAULT 'pending', -- pending, confirmed, printing, ready, delivered, cancelled
total_rub FLOAT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
---
## Docker Compose
```yaml
version: "3.8"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: print3d
POSTGRES_USER: print3d
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://print3d:${DB_PASSWORD}@db:5432/print3d
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
UPLOAD_DIR: /app/uploads
volumes:
- uploads:/app/uploads
depends_on:
- db
ports:
- "8000:8000"
frontend:
build: ./frontend
ports:
- "5173:5173"
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/certs:/etc/nginx/certs
depends_on:
- backend
- frontend
volumes:
pgdata:
uploads:
```
---
## Переменные окружения (.env)
```
DB_PASSWORD=<strong_password>
ANTHROPIC_API_KEY=<claude_api_key>
TELEGRAM_BOT_TOKEN=<telegram_bot_token>
TELEGRAM_CHAT_ID=<your_telegram_chat_id>
```
---
## Порядок реализации
### Фаза 1 — Backend core (3-4 дня)
1. Инициализация FastAPI проекта, Docker, PostgreSQL
2. Модели SQLAlchemy + Alembic миграции
3. Seed данных по материалам
4. Сервис парсинга файлов (trimesh)
5. Сервис расчёта цены (price engine)
6. Endpoints: POST /api/calculate, GET /api/materials
### Фаза 2 — Frontend core (3-4 дня)
1. Инициализация Vue 3 + Vite + Tailwind
2. FileUploader компонент
3. MaterialPicker компонент
4. PrintSettings + PriceResult компоненты
5. Интеграция с API (Pinia stores + Axios)
### Фаза 3 — AI + Orders (2-3 дня)
1. AI Advisor — интеграция Claude API
2. AiAdvisor компонент (фронт)
3. OrderForm компонент + POST /api/orders
4. Telegram-уведомления
### Фаза 4 — Деплой (1-2 дня)
1. Docker Compose конфигурация
2. Nginx конфиг (reverse proxy + SSL)
3. Деплой на VPS
4. Тестирование end-to-end
---
## Что НЕ входит в MVP (v2)
- 3D-превью модели в браузере (Three.js + STLLoader)
- Парсинг STEP-файлов (требует cadquery / OpenCASCADE)
- Личный кабинет клиента
- Онлайн-оплата (ЮKassa / Stripe)
- История заказов
- Тёмная тема
- Скачивание расчёта в PDF
- Мультиязычность
- SEO-оптимизация (SSR / pre-rendering)

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

36
backend/alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://print3d:print3d@db:5432/print3d
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

49
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,49 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.database import Base
from app.models import Material, Calculation, Order
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online():
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

0
backend/app/__init__.py Normal file
View File

21
backend/app/config.py Normal file
View File

@@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d"
GOOGLE_API_KEY: str = ""
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
UPLOAD_DIR: str = "/app/uploads"
MAX_FILE_SIZE_MB: int = 50
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "filam3d"
MINIO_SECURE: bool = False
model_config = {"env_file": ["../.env", ".env"]}
settings = Settings()

24
backend/app/database.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
logger = logging.getLogger("app.database")
logger.info("Initializing database engine: %s", settings.DATABASE_URL.split("@")[-1]) # log host only, not password
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
logger.debug("Opening database session")
async with async_session() as session:
yield session
logger.debug("Database session closed")

90
backend/app/main.py Normal file
View File

@@ -0,0 +1,90 @@
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.database import async_session, engine, Base
from app.models import Material
from app.seed.materials import MATERIALS
from app.routers import calculate, materials, orders, ai_advisor
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger("app.main")
# Reduce noise from third-party libs
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.INFO)
logging.getLogger("httpcore").setLevel(logging.INFO)
logging.getLogger("trimesh").setLevel(logging.WARNING)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("=== Application startup ===")
logger.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created successfully")
logger.info("Checking seed data...")
async with async_session() as session:
result = await session.execute(select(Material).limit(1))
if result.scalar_one_or_none() is None:
logger.info("No materials found, seeding %d materials...", len(MATERIALS))
for mat_data in MATERIALS:
session.add(Material(**mat_data))
logger.debug(" Seeded material: %s", mat_data["name"])
await session.commit()
logger.info("Materials seeded successfully")
else:
logger.info("Materials already exist, skipping seed")
logger.info("=== Application ready ===")
yield
logger.info("=== Application shutdown ===")
app = FastAPI(title="3D Print Calculator", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
logger.info("--> %s %s (client: %s)", request.method, request.url.path, request.client.host if request.client else "unknown")
logger.debug(" Headers: %s", dict(request.headers))
logger.debug(" Query params: %s", dict(request.query_params))
response = await call_next(request)
logger.info("<-- %s %s -> %d", request.method, request.url.path, response.status_code)
return response
app.include_router(calculate.router, prefix="/api")
app.include_router(materials.router, prefix="/api")
app.include_router(orders.router, prefix="/api")
app.include_router(ai_advisor.router, prefix="/api")
@app.get("/api/health")
async def health():
logger.debug("Health check requested")
return {"status": "ok"}

View File

@@ -0,0 +1,5 @@
from app.models.material import Material
from app.models.calculation import Calculation
from app.models.order import Order
__all__ = ["Material", "Calculation", "Order"]

View File

@@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Float, Integer, String, Boolean, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Calculation(Base):
__tablename__ = "calculations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_format: Mapped[str] = mapped_column(String(10), nullable=False)
file_path: Mapped[str | None] = mapped_column(String(500))
volume_cm3: Mapped[float] = mapped_column(Float, nullable=False)
surface_area_cm2: Mapped[float | None] = mapped_column(Float)
bounding_box: Mapped[dict | None] = mapped_column(JSONB)
is_watertight: Mapped[bool | None] = mapped_column(Boolean)
triangle_count: Mapped[int | None] = mapped_column(Integer)
material_id: Mapped[int] = mapped_column(Integer, ForeignKey("materials.id"), nullable=False)
infill_percent: Mapped[int] = mapped_column(Integer, default=30)
layer_height_mm: Mapped[float] = mapped_column(Float, default=0.2)
quantity: Mapped[int] = mapped_column(Integer, default=1)
post_processing: Mapped[dict] = mapped_column(JSONB, default=list)
weight_grams: Mapped[float | None] = mapped_column(Float)
material_cost_rub: Mapped[float | None] = mapped_column(Float)
time_cost_rub: Mapped[float | None] = mapped_column(Float)
print_time_hours: Mapped[float | None] = mapped_column(Float)
post_processing_cost_rub: Mapped[float | None] = mapped_column(Float)
total_rub: Mapped[float | None] = mapped_column(Float)
estimated_days: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(default=func.now())

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Boolean, Float, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Material(Base):
__tablename__ = "materials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
density_g_cm3: Mapped[float] = mapped_column(Float, nullable=False)
price_per_gram: Mapped[float] = mapped_column(Float, nullable=False)
flow_rate_mm3_s: Mapped[float] = mapped_column(Float, nullable=False)
max_temp_c: Mapped[int | None] = mapped_column(Integer)
min_temp_c: Mapped[int | None] = mapped_column(Integer)
strength: Mapped[str | None] = mapped_column(String(20))
flexibility: Mapped[str | None] = mapped_column(String(20))
chemical_resistance: Mapped[str | None] = mapped_column(String(20))
uv_resistance: Mapped[str | None] = mapped_column(String(20))
food_safe: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
color_options: Mapped[dict] = mapped_column(JSONB, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())

View File

@@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Float, Integer, String, Text, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
calculation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("calculations.id"), nullable=False)
client_name: Mapped[str] = mapped_column(String(200), nullable=False)
client_phone: Mapped[str] = mapped_column(String(20), nullable=False)
client_email: Mapped[str | None] = mapped_column(String(200))
client_company: Mapped[str | None] = mapped_column(String(200))
delivery_method: Mapped[str] = mapped_column(String(50), default="pickup")
comment: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(30), default="pending")
total_rub: Mapped[float] = mapped_column(Float, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

View File

@@ -0,0 +1,76 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.material import Material
from app.schemas.calculate import AdvisorRequest, AdvisorResponse, AdvisorAlternative
from app.services.ai_advisor import get_material_recommendation
logger = logging.getLogger("app.routers.ai_advisor")
router = APIRouter()
@router.post("/advisor", response_model=AdvisorResponse)
async def advisor(request: AdvisorRequest, db: AsyncSession = Depends(get_db)):
logger.info("===== POST /api/advisor =====")
logger.info("Task description: %s", request.task_description)
logger.info("Budget preference: %s", request.budget_preference)
logger.info("File info: %s", request.file_info)
logger.info("Fetching materials from database...")
result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id))
materials = result.scalars().all()
logger.info("Found %d active materials", len(materials))
materials_data = [
{
"id": m.id,
"name": m.name,
"category": m.category,
"density_g_cm3": m.density_g_cm3,
"price_per_gram": m.price_per_gram,
"max_temp_c": m.max_temp_c,
"min_temp_c": m.min_temp_c,
"strength": m.strength,
"flexibility": m.flexibility,
"chemical_resistance": m.chemical_resistance,
"uv_resistance": m.uv_resistance,
"food_safe": m.food_safe,
"description": m.description,
}
for m in materials
]
logger.info("Calling AI advisor service...")
try:
rec = await get_material_recommendation(
task_description=request.task_description,
materials_data=materials_data,
budget_preference=request.budget_preference,
file_info=request.file_info,
)
logger.info("AI advisor returned recommendation: material_id=%s, name=%s",
rec.get("recommended_material_id"), rec.get("recommended_material_name"))
except ValueError as e:
logger.error("AI advisor ValueError: %s", str(e))
raise HTTPException(400, str(e))
except Exception as e:
logger.error("AI advisor unexpected error: %s", str(e), exc_info=True)
raise HTTPException(500, f"Ошибка AI-сервиса: {str(e)}")
response = AdvisorResponse(
recommended_material_id=rec.get("recommended_material_id", 1),
recommended_material_name=rec.get("recommended_material_name", ""),
reasoning=rec.get("reasoning", ""),
alternatives=[
AdvisorAlternative(**alt) for alt in rec.get("alternatives", [])
],
questions=rec.get("questions", []),
)
logger.info("===== /api/advisor complete =====")
return response

View File

@@ -0,0 +1,200 @@
import logging
import os
import tempfile
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.material import Material
from app.models.calculation import Calculation
from app.schemas.calculate import (
BoundingBox,
CalculateResponse,
CalculationResult,
FileInfoResponse,
MaterialInfo,
)
from app.services.file_parser import FileInfo, parse_3d_file, SUPPORTED_EXTENSIONS
from app.services.price_engine import calculate_price
from app.services.storage import upload_file, delete_file
logger = logging.getLogger("app.routers.calculate")
router = APIRouter()
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
@router.post("/calculate", response_model=CalculateResponse)
async def calculate(
file: UploadFile = File(...),
material_id: int = Form(...),
infill_percent: int = Form(30),
layer_height_mm: float = Form(0.2),
quantity: int = Form(1),
post_processing: str = Form(""),
db: AsyncSession = Depends(get_db),
):
logger.info("===== /api/calculate request =====")
# Validate extension
filename = file.filename or "unknown"
ext = os.path.splitext(filename)[1].lower()
logger.info("File: name=%s, ext=%s, content_type=%s", filename, ext, file.content_type)
if ext not in SUPPORTED_EXTENSIONS:
logger.warning("Unsupported file extension: %s (allowed: %s)", ext, SUPPORTED_EXTENSIONS)
raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}")
# Validate params
logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, post_processing='%s'",
material_id, infill_percent, layer_height_mm, quantity, post_processing)
if not 10 <= infill_percent <= 100:
logger.warning("Invalid infill_percent: %d", infill_percent)
raise HTTPException(400, "infill_percent должен быть от 10 до 100")
if not 0.08 <= layer_height_mm <= 0.4:
logger.warning("Invalid layer_height_mm: %.2f", layer_height_mm)
raise HTTPException(400, "layer_height_mm должен быть от 0.08 до 0.4")
if not 1 <= quantity <= 500:
logger.warning("Invalid quantity: %d", quantity)
raise HTTPException(400, "quantity должен быть от 1 до 500")
# Read file
logger.info("Reading uploaded file...")
content = await file.read()
file_size = len(content)
logger.info("File read: %d bytes (%.2f MB)", file_size, file_size / 1024 / 1024)
if file_size > MAX_FILE_SIZE:
logger.warning("File too large: %d bytes (max %d)", file_size, MAX_FILE_SIZE)
raise HTTPException(413, "Файл слишком большой (максимум 50 MB)")
# Save to temp file for parsing, then upload to MinIO
file_id = str(uuid.uuid4())
object_name = f"uploads/{file_id}{ext}"
tmp_path = None
logger.info("Generated file_id: %s, object_name: %s", file_id, object_name)
try:
logger.debug("Writing to temp file for parsing...")
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
logger.debug("Temp file created: %s", tmp_path)
logger.info("Parsing 3D file...")
file_info = parse_3d_file(tmp_path, ext)
logger.info("File parsed successfully: volume=%.2f cm3, triangles=%d, watertight=%s",
file_info.volume_cm3, file_info.triangle_count, file_info.is_watertight)
except Exception as e:
logger.error("File parsing failed: %s", str(e), exc_info=True)
raise HTTPException(400, f"Ошибка парсинга файла: {str(e)}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
logger.debug("Temp file removed: %s", tmp_path)
# Upload to MinIO
try:
logger.info("Uploading to MinIO: %s", object_name)
upload_file(object_name, content)
logger.info("MinIO upload complete")
except Exception as e:
logger.error("MinIO upload failed: %s", str(e), exc_info=True)
raise HTTPException(500, f"Ошибка загрузки файла в хранилище: {str(e)}")
# Get material
logger.info("Looking up material id=%d...", material_id)
result = await db.execute(select(Material).where(Material.id == material_id))
material = result.scalar_one_or_none()
if not material:
logger.warning("Material not found: id=%d", material_id)
raise HTTPException(400, f"Материал с id={material_id} не найден")
logger.info("Material found: id=%d, name=%s, density=%.2f, price=%.1f RUB/g",
material.id, material.name, material.density_g_cm3, material.price_per_gram)
# Parse post_processing
pp_list = [p.strip() for p in post_processing.split(",") if p.strip()] if post_processing else []
logger.info("Post-processing options: %s", pp_list)
# Calculate price
logger.info("Calculating price...")
price = calculate_price(
file_info=file_info,
density_g_cm3=material.density_g_cm3,
price_per_gram=material.price_per_gram,
flow_rate_mm3_s=material.flow_rate_mm3_s,
infill_percent=infill_percent,
layer_height_mm=layer_height_mm,
quantity=quantity,
post_processing=pp_list,
)
# Save calculation to DB
logger.info("Saving calculation to database...")
calc = Calculation(
file_name=filename,
file_format=ext.lstrip("."),
file_path=object_name,
volume_cm3=file_info.volume_cm3,
surface_area_cm2=file_info.surface_area_cm2,
bounding_box=file_info.bounding_box_mm,
is_watertight=file_info.is_watertight,
triangle_count=file_info.triangle_count,
material_id=material.id,
infill_percent=infill_percent,
layer_height_mm=layer_height_mm,
quantity=quantity,
post_processing=pp_list,
weight_grams=price.weight_grams,
material_cost_rub=price.material_cost_rub,
time_cost_rub=price.time_cost_rub,
print_time_hours=price.print_time_hours,
post_processing_cost_rub=price.post_processing_cost_rub,
total_rub=price.total_rub,
estimated_days=price.estimated_days,
)
db.add(calc)
await db.commit()
await db.refresh(calc)
logger.info("Calculation saved: id=%s, total=%.2f RUB", calc.id, price.total_rub)
logger.info("===== /api/calculate complete: %s -> %.2f RUB =====", filename, price.total_rub)
return CalculateResponse(
success=True,
calculation_id=str(calc.id),
file_info=FileInfoResponse(
filename=filename,
format=ext.lstrip("."),
volume_cm3=round(file_info.volume_cm3, 2),
surface_area_cm2=round(file_info.surface_area_cm2, 2),
bounding_box_mm=BoundingBox(**file_info.bounding_box_mm),
is_watertight=file_info.is_watertight,
triangle_count=file_info.triangle_count,
),
calculation=CalculationResult(
material=MaterialInfo(
id=material.id,
name=material.name,
density_g_cm3=material.density_g_cm3,
price_per_gram=material.price_per_gram,
),
weight_grams=price.weight_grams,
material_cost_rub=price.material_cost_rub,
print_time_hours=price.print_time_hours,
time_cost_rub=price.time_cost_rub,
post_processing_cost_rub=price.post_processing_cost_rub,
subtotal_rub=price.subtotal_rub,
quantity=price.quantity,
quantity_discount_percent=price.quantity_discount_percent,
total_rub=price.total_rub,
estimated_days=price.estimated_days,
),
)

View File

@@ -0,0 +1,52 @@
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.material import Material
from app.schemas.material import MaterialResponse, MaterialProperties
logger = logging.getLogger("app.routers.materials")
router = APIRouter()
@router.get("/materials", response_model=list[MaterialResponse])
async def get_materials(db: AsyncSession = Depends(get_db)):
logger.info("GET /api/materials")
result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id))
materials = result.scalars().all()
logger.info("Found %d active materials", len(materials))
response = [
MaterialResponse(
id=m.id,
name=m.name,
category=m.category,
price_per_gram=m.price_per_gram,
density_g_cm3=m.density_g_cm3,
flow_rate_mm3_s=m.flow_rate_mm3_s,
properties=MaterialProperties(
max_temp_c=m.max_temp_c,
min_temp_c=m.min_temp_c,
strength=m.strength,
flexibility=m.flexibility,
chemical_resistance=m.chemical_resistance,
uv_resistance=m.uv_resistance,
food_safe=m.food_safe,
),
description=m.description,
color_options=m.color_options or [],
)
for m in materials
]
for m in materials:
logger.debug(" Material: id=%d, name=%s, category=%s, price=%.1f RUB/g",
m.id, m.name, m.category, m.price_per_gram)
logger.info("Returning %d materials", len(response))
return response

View File

@@ -0,0 +1,106 @@
import logging
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.calculation import Calculation
from app.models.material import Material
from app.models.order import Order
from app.schemas.order import OrderCreate, OrderResponse
from app.services.telegram_notify import notify_new_order
logger = logging.getLogger("app.routers.orders")
router = APIRouter()
async def generate_order_id(db: AsyncSession) -> str:
year = datetime.now().year
result = await db.execute(
select(func.count(Order.id)).where(Order.order_id.like(f"ORD-{year}-%"))
)
count = result.scalar() or 0
order_id = f"ORD-{year}-{count + 1:04d}"
logger.debug("Generated order_id: %s (existing count: %d)", order_id, count)
return order_id
@router.post("/orders", response_model=OrderResponse)
async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_db)):
logger.info("===== POST /api/orders =====")
logger.info("Client: name=%s, phone=%s, email=%s, company=%s",
order_data.client_name, order_data.client_phone,
order_data.client_email, order_data.client_company)
logger.info("Calculation ID: %s", order_data.calculation_id)
logger.info("Delivery: %s, comment: %s", order_data.delivery_method, order_data.comment)
# Get calculation
try:
calc_uuid = uuid.UUID(order_data.calculation_id)
except ValueError:
logger.warning("Invalid calculation_id format: %s", order_data.calculation_id)
raise HTTPException(400, "Некорректный calculation_id")
logger.info("Looking up calculation: %s", calc_uuid)
result = await db.execute(select(Calculation).where(Calculation.id == calc_uuid))
calc = result.scalar_one_or_none()
if not calc:
logger.warning("Calculation not found: %s", calc_uuid)
raise HTTPException(404, "Расчёт не найден")
logger.info("Calculation found: total=%.2f RUB, material_id=%d, estimated_days=%s",
calc.total_rub, calc.material_id, calc.estimated_days)
# Get material name for notification
logger.debug("Looking up material id=%d for notification...", calc.material_id)
mat_result = await db.execute(select(Material).where(Material.id == calc.material_id))
material = mat_result.scalar_one_or_none()
material_name = material.name if material else "Неизвестный"
logger.info("Material for notification: %s", material_name)
order_id = await generate_order_id(db)
estimated_ready = datetime.now() + timedelta(days=calc.estimated_days or 3)
logger.info("Order ID: %s, estimated ready: %s", order_id, estimated_ready.strftime("%Y-%m-%d"))
order = Order(
order_id=order_id,
calculation_id=calc.id,
client_name=order_data.client_name,
client_phone=order_data.client_phone,
client_email=order_data.client_email,
client_company=order_data.client_company,
delivery_method=order_data.delivery_method,
comment=order_data.comment,
total_rub=calc.total_rub,
)
db.add(order)
await db.commit()
await db.refresh(order)
logger.info("Order saved to database: id=%d, order_id=%s", order.id, order_id)
# Send Telegram notification
logger.info("Sending Telegram notification...")
try:
await notify_new_order(
order_id=order_id,
client_name=order_data.client_name,
client_phone=order_data.client_phone,
material_name=material_name,
total_rub=calc.total_rub,
comment=order_data.comment,
)
logger.info("Telegram notification sent successfully")
except Exception:
logger.exception("Telegram notification failed (order still created)")
logger.info("===== Order created: %s -> %.2f RUB =====", order_id, calc.total_rub)
return OrderResponse(
order_id=order_id,
status="pending",
total_rub=calc.total_rub,
estimated_ready_date=estimated_ready.strftime("%Y-%m-%d"),
)

View File

View File

@@ -0,0 +1,65 @@
from pydantic import BaseModel, Field
class BoundingBox(BaseModel):
x: float
y: float
z: float
class FileInfoResponse(BaseModel):
filename: str
format: str
volume_cm3: float
surface_area_cm2: float
bounding_box_mm: BoundingBox
is_watertight: bool
triangle_count: int
class MaterialInfo(BaseModel):
id: int
name: str
density_g_cm3: float
price_per_gram: float
class CalculationResult(BaseModel):
material: MaterialInfo
weight_grams: float
material_cost_rub: float
print_time_hours: float
time_cost_rub: float
post_processing_cost_rub: float
subtotal_rub: float
quantity: int
quantity_discount_percent: int
total_rub: float
estimated_days: int
class CalculateResponse(BaseModel):
success: bool = True
calculation_id: str
file_info: FileInfoResponse
calculation: CalculationResult
class AdvisorRequest(BaseModel):
task_description: str
budget_preference: str = "optimal"
file_info: dict | None = None
class AdvisorAlternative(BaseModel):
material_id: int
name: str
why: str
class AdvisorResponse(BaseModel):
recommended_material_id: int
recommended_material_name: str
reasoning: str
alternatives: list[AdvisorAlternative] = []
questions: list[str] = []

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
class MaterialProperties(BaseModel):
max_temp_c: int | None = None
min_temp_c: int | None = None
strength: str | None = None
flexibility: str | None = None
chemical_resistance: str | None = None
uv_resistance: str | None = None
food_safe: bool = False
class MaterialResponse(BaseModel):
id: int
name: str
category: str
price_per_gram: float
density_g_cm3: float
flow_rate_mm3_s: float
properties: MaterialProperties
description: str | None = None
color_options: list[str] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
calculation_id: str
client_name: str
client_phone: str = Field(pattern=r"^\+?\d{10,15}$")
client_email: str | None = None
client_company: str | None = None
delivery_method: str = "pickup"
comment: str | None = None
class OrderResponse(BaseModel):
order_id: str
status: str
total_rub: float
estimated_ready_date: str

View File

View File

@@ -0,0 +1,114 @@
MATERIALS = [
{
"name": "PLA",
"category": "basic",
"density_g_cm3": 1.24,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 15.0,
"max_temp_c": 60,
"min_temp_c": -20,
"strength": "medium",
"flexibility": "low",
"chemical_resistance": "low",
"uv_resistance": "low",
"food_safe": True,
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.",
"color_options": ["white", "black", "gray", "red", "blue", "green", "natural"],
},
{
"name": "PETG",
"category": "basic",
"density_g_cm3": 1.27,
"price_per_gram": 28.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 80,
"min_temp_c": -40,
"strength": "high",
"flexibility": "medium",
"chemical_resistance": "medium",
"uv_resistance": "medium",
"food_safe": True,
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.",
"color_options": ["white", "black", "gray", "natural", "blue"],
},
{
"name": "ABS",
"category": "basic",
"density_g_cm3": 1.04,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 100,
"min_temp_c": -30,
"strength": "high",
"flexibility": "low",
"chemical_resistance": "medium",
"uv_resistance": "low",
"food_safe": False,
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.",
"color_options": ["white", "black", "gray", "red", "blue"],
},
{
"name": "PA (Nylon)",
"category": "engineering",
"density_g_cm3": 1.14,
"price_per_gram": 50.0,
"flow_rate_mm3_s": 10.0,
"max_temp_c": 120,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "medium",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
"color_options": ["natural", "black"],
},
{
"name": "PC (Поликарбонат)",
"category": "engineering",
"density_g_cm3": 1.20,
"price_per_gram": 60.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 140,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "low",
"chemical_resistance": "high",
"uv_resistance": "high",
"food_safe": False,
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
"color_options": ["natural", "black"],
},
{
"name": "TPU",
"category": "engineering",
"density_g_cm3": 1.21,
"price_per_gram": 40.0,
"flow_rate_mm3_s": 6.0,
"max_temp_c": 80,
"min_temp_c": -30,
"strength": "medium",
"flexibility": "very_high",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
"color_options": ["white", "black", "natural"],
},
{
"name": "PA-CF (Нейлон + углеволокно)",
"category": "composite",
"density_g_cm3": 1.18,
"price_per_gram": 75.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 150,
"min_temp_c": -40,
"strength": "extreme",
"flexibility": "low",
"chemical_resistance": "very_high",
"uv_resistance": "high",
"food_safe": False,
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
"color_options": ["black"],
},
]

View File

View File

@@ -0,0 +1,104 @@
import json
import logging
import time
from google import genai
from google.genai import types
from app.config import settings
logger = logging.getLogger("app.services.ai_advisor")
SYSTEM_PROMPT = """
Ты — эксперт по 3D-печати из инженерных пластиков по технологии FDM.
Твоя задача — рекомендовать оптимальный материал для печати на основе описания задачи клиента.
Доступные материалы:
{materials_json}
Правила:
1. Всегда рекомендуй один основной материал и 1-2 альтернативы.
2. Учитывай: температурный режим, механические нагрузки, химическое воздействие, UV, влажность.
3. Если задача не подходит для FDM-печати (слишком мелкие детали, высокая точность) — честно скажи об этом.
4. Отвечай кратко, по делу, на русском языке.
5. Если клиент не указал критичные параметры — задай уточняющие вопросы.
Формат ответа — строго JSON:
{{
"recommended_material_id": <int>,
"recommended_material_name": "<str>",
"reasoning": "<обоснование на русском>",
"alternatives": [{{"material_id": <int>, "name": "<str>", "why": "<причина>"}}],
"questions": ["<вопрос, если нужна доп. информация>"]
}}
"""
async def get_material_recommendation(
task_description: str,
materials_data: list[dict],
budget_preference: str = "optimal",
file_info: dict | None = None,
) -> dict:
"""Get material recommendation from Google Gemini API."""
logger.info("=== AI Advisor request ===")
logger.info("Task: %s", task_description)
logger.info("Budget preference: %s", budget_preference)
logger.info("File info: %s", file_info)
logger.info("Materials count: %d", len(materials_data))
if not settings.GOOGLE_API_KEY:
logger.error("GOOGLE_API_KEY is not configured")
raise ValueError("GOOGLE_API_KEY not configured")
materials_json = json.dumps(materials_data, ensure_ascii=False, indent=2)
system = SYSTEM_PROMPT.format(materials_json=materials_json)
logger.debug("System prompt length: %d chars", len(system))
user_message = f"Описание задачи: {task_description}\nПредпочтение по бюджету: {budget_preference}"
if file_info:
user_message += f"\nИнформация о модели: {json.dumps(file_info, ensure_ascii=False)}"
logger.debug("User message: %s", user_message)
logger.info("Sending request to Gemini API (model: gemini-2.0-flash)...")
start_time = time.time()
client = genai.Client(api_key=settings.GOOGLE_API_KEY)
response = await client.aio.models.generate_content(
model="gemini-3-flash-preview",
contents=user_message,
config=types.GenerateContentConfig(
system_instruction=system,
max_output_tokens=1024,
temperature=0.3,
),
)
elapsed = time.time() - start_time
logger.info("Gemini API responded in %.2f seconds", elapsed)
response_text = response.text
logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500])
try:
result = json.loads(response_text)
logger.info("Response parsed as JSON successfully")
logger.info("Recommended material: id=%s, name=%s",
result.get("recommended_material_id"), result.get("recommended_material_name"))
logger.info("Alternatives: %d, Questions: %d",
len(result.get("alternatives", [])), len(result.get("questions", [])))
logger.info("=== AI Advisor complete ===")
return result
except json.JSONDecodeError:
logger.warning("Direct JSON parse failed, trying to extract JSON from response...")
start = response_text.find("{")
end = response_text.rfind("}") + 1
if start != -1 and end > start:
extracted = response_text[start:end]
logger.debug("Extracted JSON substring [%d:%d]: %s", start, end, extracted[:300])
result = json.loads(extracted)
logger.info("Extracted JSON parsed successfully")
logger.info("=== AI Advisor complete ===")
return result
logger.error("Failed to extract JSON from AI response: %s", response_text[:200])
raise ValueError("AI вернул невалидный JSON")

View File

@@ -0,0 +1,62 @@
import logging
from dataclasses import dataclass
import trimesh
logger = logging.getLogger("app.services.file_parser")
@dataclass
class FileInfo:
volume_cm3: float
surface_area_cm2: float
bounding_box_mm: dict[str, float]
is_watertight: bool
triangle_count: int
SUPPORTED_EXTENSIONS = {".stl", ".3mf", ".obj"}
def parse_3d_file(file_path: str, file_extension: str) -> FileInfo:
"""Parse a 3D file and return geometric properties."""
ext = file_extension.lower().lstrip(".")
logger.info("Parsing 3D file: path=%s, extension=%s", file_path, ext)
logger.debug("Loading mesh with trimesh (file_type=%s)...", ext)
mesh = trimesh.load(file_path, file_type=ext)
logger.debug("Trimesh loaded object type: %s", type(mesh).__name__)
if isinstance(mesh, trimesh.Scene):
meshes = list(mesh.dump())
logger.info("File is a Scene with %d geometries, concatenating...", len(meshes))
if not meshes:
logger.error("Scene contains no geometries")
raise ValueError("Файл не содержит 3D-геометрии")
mesh = trimesh.util.concatenate(meshes)
logger.debug("Concatenated into single Trimesh")
if not isinstance(mesh, trimesh.Trimesh):
logger.error("Could not extract Trimesh object, got: %s", type(mesh).__name__)
raise ValueError("Не удалось извлечь 3D-геометрию из файла")
volume_cm3 = abs(mesh.volume) / 1000.0
surface_area_cm2 = mesh.area / 100.0
bbox = {
"x": round(float(mesh.bounding_box.extents[0]), 2),
"y": round(float(mesh.bounding_box.extents[1]), 2),
"z": round(float(mesh.bounding_box.extents[2]), 2),
}
is_watertight = bool(mesh.is_watertight)
triangle_count = len(mesh.faces)
logger.info("Parse result: volume=%.2f cm3, area=%.2f cm2, bbox=%s, watertight=%s, triangles=%d",
volume_cm3, surface_area_cm2, bbox, is_watertight, triangle_count)
return FileInfo(
volume_cm3=volume_cm3,
surface_area_cm2=surface_area_cm2,
bounding_box_mm=bbox,
is_watertight=is_watertight,
triangle_count=triangle_count,
)

View File

@@ -0,0 +1,140 @@
import logging
from dataclasses import dataclass
from app.services.file_parser import FileInfo
logger = logging.getLogger("app.services.price_engine")
TIME_RATE_PER_HOUR = 200.0 # руб/час
SETUP_TIME_MIN = 15.0 # минуты
TRAVEL_TIME_PER_LAYER_MIN = 0.3
POST_PROCESSING_COSTS = {
"sanding": 300.0,
"painting": 500.0,
"threading": 200.0,
"acetone_smoothing": 400.0,
}
QUANTITY_DISCOUNTS = [
(1, 0),
(2, 5),
(6, 10),
(21, 15),
(101, 20),
]
@dataclass
class PriceResult:
weight_grams: float
material_cost_rub: float
print_time_hours: float
time_cost_rub: float
post_processing_cost_rub: float
subtotal_rub: float
quantity: int
quantity_discount_percent: int
total_rub: float
estimated_days: int
def get_quantity_discount(quantity: int) -> int:
discount = 0
for min_qty, disc in QUANTITY_DISCOUNTS:
if quantity >= min_qty:
discount = disc
logger.debug("Quantity discount for %d pcs: %d%%", quantity, discount)
return discount
def estimate_print_time(file_info: FileInfo, layer_height_mm: float, flow_rate_mm3_s: float) -> float:
"""Estimate print time in hours."""
z_height = file_info.bounding_box_mm.get("z", 10.0)
layers = max(z_height / layer_height_mm, 1)
volume_mm3 = file_info.volume_cm3 * 1000.0
volume_per_layer = volume_mm3 / layers
time_per_layer_min = volume_per_layer / flow_rate_mm3_s / 60.0
total_min = layers * (time_per_layer_min + TRAVEL_TIME_PER_LAYER_MIN) + SETUP_TIME_MIN
hours = round(total_min / 60.0, 1)
logger.debug("Print time estimate: z=%.1fmm, layers=%.0f, vol_per_layer=%.1fmm3, "
"time_per_layer=%.2fmin, total=%.1fmin (%.1fh)",
z_height, layers, volume_per_layer, time_per_layer_min, total_min, hours)
return hours
def calculate_price(
file_info: FileInfo,
density_g_cm3: float,
price_per_gram: float,
flow_rate_mm3_s: float,
infill_percent: int = 30,
layer_height_mm: float = 0.2,
quantity: int = 1,
post_processing: list[str] | None = None,
) -> PriceResult:
post_processing = post_processing or []
logger.info("=== Price calculation start ===")
logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB",
file_info.volume_cm3, density_g_cm3, price_per_gram)
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, post_processing=%s",
infill_percent, layer_height_mm, quantity, post_processing)
effective_volume = file_info.volume_cm3 * (infill_percent / 100.0) * 0.7 + file_info.volume_cm3 * 0.3
logger.debug("Effective volume: %.2f cm3 (infill-scaled: %.2f + walls: %.2f)",
effective_volume,
file_info.volume_cm3 * (infill_percent / 100.0) * 0.7,
file_info.volume_cm3 * 0.3)
weight_g = round(effective_volume * density_g_cm3, 1)
material_cost = round(weight_g * price_per_gram, 2)
logger.debug("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost)
print_time_h = estimate_print_time(file_info, layer_height_mm, flow_rate_mm3_s)
time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2)
logger.debug("Print time: %.1f h, time cost: %.2f RUB (rate: %.0f RUB/h)", print_time_h, time_cost, TIME_RATE_PER_HOUR)
pp_cost = 0.0
for pp in post_processing:
cost = POST_PROCESSING_COSTS.get(pp, 0)
logger.debug("Post-processing '%s': %.0f RUB", pp, cost)
pp_cost += cost
pp_cost = round(pp_cost, 2)
logger.debug("Total post-processing cost: %.2f RUB", pp_cost)
subtotal = round(material_cost + time_cost + pp_cost, 2)
logger.debug("Subtotal (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
subtotal, material_cost, time_cost, pp_cost)
discount_pct = get_quantity_discount(quantity)
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)",
total, quantity, discount_pct, subtotal)
if print_time_h <= 2:
estimated_days = 2
elif print_time_h <= 8:
estimated_days = 3
else:
estimated_days = 5
if quantity > 10:
estimated_days += 2
if quantity > 50:
estimated_days += 3
logger.info("Estimated days: %d", estimated_days)
logger.info("=== Price calculation complete ===")
return PriceResult(
weight_grams=weight_g,
material_cost_rub=material_cost,
print_time_hours=print_time_h,
time_cost_rub=time_cost,
post_processing_cost_rub=pp_cost,
subtotal_rub=subtotal,
quantity=quantity,
quantity_discount_percent=discount_pct,
total_rub=total,
estimated_days=estimated_days,
)

View File

@@ -0,0 +1,67 @@
import io
import logging
from minio import Minio
from minio.error import S3Error
from app.config import settings
logger = logging.getLogger("app.services.storage")
_client: Minio | None = None
def get_minio_client() -> Minio:
global _client
if _client is None:
logger.info("Initializing MinIO client: endpoint=%s, secure=%s", settings.MINIO_ENDPOINT, settings.MINIO_SECURE)
_client = Minio(
endpoint=settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
logger.info("MinIO client created, checking bucket '%s'...", settings.MINIO_BUCKET)
if not _client.bucket_exists(settings.MINIO_BUCKET):
_client.make_bucket(settings.MINIO_BUCKET)
logger.info("Created MinIO bucket: %s", settings.MINIO_BUCKET)
else:
logger.info("MinIO bucket '%s' already exists", settings.MINIO_BUCKET)
return _client
def upload_file(object_name: str, data: bytes, content_type: str = "application/octet-stream") -> str:
"""Upload file to MinIO. Returns the object name (key)."""
logger.info("Uploading to MinIO: object=%s, size=%d bytes, content_type=%s", object_name, len(data), content_type)
client = get_minio_client()
client.put_object(
bucket_name=settings.MINIO_BUCKET,
object_name=object_name,
data=io.BytesIO(data),
length=len(data),
content_type=content_type,
)
logger.info("Upload complete: %s/%s", settings.MINIO_BUCKET, object_name)
return object_name
def download_file(object_name: str) -> bytes:
"""Download file from MinIO."""
logger.info("Downloading from MinIO: %s/%s", settings.MINIO_BUCKET, object_name)
client = get_minio_client()
response = client.get_object(settings.MINIO_BUCKET, object_name)
try:
data = response.read()
logger.info("Download complete: %s, size=%d bytes", object_name, len(data))
return data
finally:
response.close()
response.release_conn()
def delete_file(object_name: str) -> None:
"""Delete file from MinIO."""
logger.info("Deleting from MinIO: %s/%s", settings.MINIO_BUCKET, object_name)
client = get_minio_client()
client.remove_object(settings.MINIO_BUCKET, object_name)
logger.info("Deleted: %s", object_name)

View File

@@ -0,0 +1,48 @@
import logging
import httpx
from app.config import settings
logger = logging.getLogger("app.services.telegram")
async def notify_new_order(
order_id: str,
client_name: str,
client_phone: str,
material_name: str,
total_rub: float,
comment: str | None = None,
) -> None:
"""Send a notification about a new order to Telegram."""
logger.info("=== Telegram notification ===")
logger.info("Order: %s, client: %s, phone: %s, material: %s, total: %.2f RUB",
order_id, client_name, client_phone, material_name, total_rub)
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
logger.warning("Telegram credentials not configured (token=%s, chat_id=%s), skipping",
"set" if settings.TELEGRAM_BOT_TOKEN else "empty",
"set" if settings.TELEGRAM_CHAT_ID else "empty")
return
text = (
f"\U0001f195 Новый заказ #{order_id}\n"
f"Клиент: {client_name}\n"
f"Телефон: {client_phone}\n"
f"Материал: {material_name}\n"
f"Сумма: {total_rub} \u20bd\n"
f"Комментарий: {comment or '\u2014'}"
)
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
logger.debug("Sending to Telegram: chat_id=%s, text_length=%d", settings.TELEGRAM_CHAT_ID, len(text))
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json={"chat_id": settings.TELEGRAM_CHAT_ID, "text": text})
logger.info("Telegram response: status=%d, body=%s", resp.status_code, resp.text[:200])
if resp.status_code != 200:
logger.error("Telegram API error: %s", resp.text)
except Exception:
logger.exception("Failed to send Telegram notification")

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
pydantic-settings==2.7.1
alembic==1.14.1
trimesh==4.5.3
numpy-stl==3.1.2
numpy==2.2.1
python-multipart==0.0.20
httpx==0.28.1
minio==7.2.12
google-genai==1.14.0

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: "3.8"
services:
backend:
build: ./backend
network_mode: host
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://print3d:print3d_secret@localhost:5432/print3d}
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
MINIO_ENDPOINT: localhost:9000
MINIO_ACCESS_KEY: admin
MINIO_SECRET_KEY: SuperSecretPassword123!
MINIO_BUCKET: ${MINIO_BUCKET:-filam3d}
MINIO_SECURE: ${MINIO_SECURE:-false}
frontend:
build: ./frontend
network_mode: host

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8091/api

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Filam3D — Калькулятор 3D-печати</title>
</head>
<body class="bg-gray-50 font-sans text-gray-900 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2727
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "filam3d-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.9"
},
"devDependencies": {
"vite": "^6.0.7",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

35
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 sm:px-6">
<router-link to="/" class="flex items-center gap-2.5">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
</div>
<span class="text-lg font-bold text-gray-900">Filam3D</span>
</router-link>
<nav class="flex items-center gap-1">
<router-link
to="/"
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
active-class="!bg-primary-50 !text-primary-700"
>
Калькулятор
</router-link>
<router-link
to="/materials"
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
active-class="!bg-primary-50 !text-primary-700"
>
Материалы
</router-link>
</nav>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<router-view />
</main>
</div>
</template>

View File

@@ -0,0 +1,8 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 60000,
})
export default api

View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply min-h-screen;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-semibold text-gray-700 shadow-sm transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
.card {
@apply rounded-xl border border-gray-200 bg-white p-6 shadow-sm;
}
.input-field {
@apply block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500;
}
}

View File

@@ -0,0 +1,132 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-end bg-black/30" @click.self="$emit('close')">
<div class="mt-0 h-full w-full max-w-md bg-white shadow-xl sm:mt-0 flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<h3 class="text-base font-semibold text-gray-900">AI-ассистент по материалам</h3>
<button @click="$emit('close')" class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-5">
<p class="mb-4 text-sm text-gray-600">
Опишите вашу задачу, и AI порекомендует оптимальный материал.
</p>
<textarea
v-model="taskDescription"
rows="4"
class="input-field mb-4"
placeholder="Например: Корпус для уличного датчика температуры, диапазон -30..+50, водонепроницаемый"
></textarea>
<button @click="getRecommendation" :disabled="!taskDescription.trim() || loading" class="btn-primary w-full mb-5">
<svg v-if="loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ loading ? 'Анализирую...' : 'Получить рекомендацию' }}
</button>
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</div>
<div v-if="recommendation">
<!-- Main recommendation -->
<div class="mb-4 rounded-lg border-2 border-primary-200 bg-primary-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-primary-600">Рекомендация</span>
<button @click="selectMaterial(recommendation.recommended_material_id)" class="btn-primary !py-1 !px-3 !text-xs">
Выбрать
</button>
</div>
<p class="text-base font-bold text-gray-900 mb-1.5">{{ recommendation.recommended_material_name }}</p>
<p class="text-sm text-gray-700 leading-relaxed">{{ recommendation.reasoning }}</p>
</div>
<!-- Alternatives -->
<div v-if="recommendation.alternatives?.length" class="space-y-2.5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500">Альтернативы</p>
<div v-for="alt in recommendation.alternatives" :key="alt.material_id" class="flex items-start justify-between rounded-lg border border-gray-200 p-3">
<div class="flex-1">
<p class="text-sm font-semibold text-gray-900">{{ alt.name }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ alt.why }}</p>
</div>
<button @click="selectMaterial(alt.material_id)" class="btn-secondary !py-1 !px-2.5 !text-xs ml-3 shrink-0">
Выбрать
</button>
</div>
</div>
<!-- Questions -->
<div v-if="recommendation.questions?.length" class="mt-4 rounded-lg bg-amber-50 p-3">
<p class="text-xs font-semibold text-amber-700 mb-1.5">Уточняющие вопросы:</p>
<ul class="list-disc pl-4 text-sm text-amber-700 space-y-1">
<li v-for="q in recommendation.questions" :key="q">{{ q }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
import api from '../api/client'
import { useCalculatorStore } from '../stores/calculator'
defineProps({ open: Boolean })
const emit = defineEmits(['close'])
const store = useCalculatorStore()
const taskDescription = ref('')
const loading = ref(false)
const error = ref('')
const recommendation = ref(null)
async function getRecommendation() {
loading.value = true
error.value = ''
try {
const payload = {
task_description: taskDescription.value,
budget_preference: 'optimal',
}
if (store.result?.file_info) {
payload.file_info = {
volume_cm3: store.result.file_info.volume_cm3,
bounding_box_mm: store.result.file_info.bounding_box_mm,
}
}
const { data } = await api.post('/advisor', payload)
recommendation.value = data
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка получения рекомендации'
} finally {
loading.value = false
}
}
function selectMaterial(id) {
store.materialId = id
store.result = null
emit('close')
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="card">
<h2 class="mb-4 text-lg font-semibold text-gray-900">1. Загрузите 3D-модель</h2>
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
:class="[
'relative flex flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 transition-all',
isDragging ? 'border-primary-400 bg-primary-50' : 'border-gray-300 hover:border-gray-400',
selectedFile ? 'bg-green-50 border-green-300' : '',
]"
>
<template v-if="!selectedFile">
<svg class="mb-3 h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<p class="mb-1 text-sm font-medium text-gray-700">
Перетащите файл сюда или
<label class="cursor-pointer text-primary-600 hover:text-primary-700">
выберите
<input type="file" class="hidden" :accept="acceptTypes" @change="onFileSelect" />
</label>
</p>
<p class="text-xs text-gray-500">STL, 3MF, OBJ до 50 МБ</p>
</template>
<template v-else>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<svg class="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">{{ selectedFile.name }}</p>
<p class="text-xs text-gray-500">{{ formatSize(selectedFile.size) }} &middot; {{ getExtension(selectedFile.name) }}</p>
</div>
<button @click.stop="removeFile" class="ml-4 rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<!-- Upload progress -->
<div v-if="store.loading && store.uploadProgress > 0" class="mt-3">
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
<span>Загрузка...</span>
<span>{{ store.uploadProgress }}%</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div class="h-1.5 rounded-full bg-primary-600 transition-all" :style="{ width: store.uploadProgress + '%' }"></div>
</div>
</div>
<p v-if="validationError" class="mt-2 text-sm text-red-600">{{ validationError }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useCalculatorStore } from '../stores/calculator'
const store = useCalculatorStore()
const isDragging = ref(false)
const validationError = ref('')
const acceptTypes = '.stl,.3mf,.obj'
const allowedExtensions = ['stl', '3mf', 'obj']
const maxSize = 50 * 1024 * 1024
const selectedFile = computed(() => store.file)
function getExtension(name) {
return name.split('.').pop().toUpperCase()
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' Б'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ'
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ'
}
function validateFile(file) {
validationError.value = ''
const ext = file.name.split('.').pop().toLowerCase()
if (!allowedExtensions.includes(ext)) {
validationError.value = `Формат .${ext} не поддерживается. Используйте STL, 3MF или OBJ.`
return false
}
if (file.size > maxSize) {
validationError.value = 'Файл слишком большой (максимум 50 МБ)'
return false
}
return true
}
function setFile(file) {
if (validateFile(file)) {
store.file = file
store.result = null
}
}
function onFileSelect(e) {
const file = e.target.files[0]
if (file) setFile(file)
}
function onDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file) setFile(file)
}
function removeFile() {
store.file = null
store.result = null
validationError.value = ''
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="card">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">2. Выберите материал</h2>
<button @click="$emit('openAdvisor')" class="btn-secondary !py-1.5 !px-3 !text-xs">
<svg class="mr-1.5 h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
Помочь выбрать
</button>
</div>
<div v-for="(label, cat) in categories" :key="cat" class="mb-5 last:mb-0">
<h3 class="mb-2.5 text-xs font-semibold uppercase tracking-wider text-gray-500">{{ label }}</h3>
<div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="mat in materialsByCategory(cat)"
:key="mat.id"
@click="selectMaterial(mat.id)"
:class="[
'flex flex-col rounded-lg border-2 p-3.5 text-left transition-all',
store.materialId === mat.id
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50',
]"
>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-900">{{ mat.name }}</span>
<span class="text-xs font-medium text-gray-500">{{ mat.price_per_gram }} &#8381;/г</span>
</div>
<p class="mt-1 text-xs leading-relaxed text-gray-500">{{ mat.description }}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<span v-if="mat.properties.food_safe" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700">
Food safe
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
{{ mat.properties.max_temp_c }}&deg;C
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
{{ strengthLabel(mat.properties.strength) }}
</span>
</div>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useCalculatorStore } from '../stores/calculator'
import { useMaterialsStore } from '../stores/materials'
defineEmits(['openAdvisor'])
const store = useCalculatorStore()
const materialsStore = useMaterialsStore()
const { categories } = materialsStore
onMounted(() => materialsStore.fetchMaterials())
function materialsByCategory(cat) {
return materialsStore.materials.filter((m) => m.category === cat)
}
function selectMaterial(id) {
store.materialId = id
store.result = null
}
const strengthLabels = {
low: 'Низкая',
medium: 'Средняя',
high: 'Высокая',
very_high: 'Очень высокая',
extreme: 'Экстремальная',
}
function strengthLabel(val) {
return strengthLabels[val] || val
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<form @submit.prevent="submitOrder" class="space-y-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Имя *</label>
<input v-model="form.client_name" required class="input-field" placeholder="Иван Петров" />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Телефон *</label>
<input v-model="form.client_phone" required class="input-field" placeholder="+79001234567" />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input v-model="form.client_email" type="email" class="input-field" placeholder="ivan@example.com" />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Компания</label>
<input v-model="form.client_company" class="input-field" placeholder="ООО Технопарк" />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Способ получения</label>
<select v-model="form.delivery_method" class="input-field">
<option value="pickup">Самовывоз</option>
<option value="delivery">Доставка</option>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Комментарий</label>
<textarea v-model="form.comment" rows="3" class="input-field" placeholder="Дополнительные пожелания"></textarea>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button type="submit" :disabled="loading" class="btn-primary w-full">
<svg v-if="loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ loading ? 'Оформляем...' : 'Оформить заказ' }}
</button>
</form>
</template>
<script setup>
import { reactive, ref } from 'vue'
import api from '../api/client'
const props = defineProps({ calculationId: String })
const emit = defineEmits(['success'])
const form = reactive({
client_name: '',
client_phone: '',
client_email: '',
client_company: '',
delivery_method: 'pickup',
comment: '',
})
const loading = ref(false)
const error = ref('')
async function submitOrder() {
loading.value = true
error.value = ''
try {
const { data } = await api.post('/orders', {
calculation_id: props.calculationId,
...form,
})
emit('success', data)
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка оформления заказа'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="card" v-if="result">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Результат расчёта</h2>
<!-- File info -->
<div class="mb-4 rounded-lg bg-gray-50 p-3.5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Модель</p>
<div class="grid grid-cols-2 gap-2 text-sm">
<div><span class="text-gray-500">Файл:</span> {{ result.file_info.filename }}</div>
<div><span class="text-gray-500">Объём:</span> {{ result.file_info.volume_cm3 }} см&sup3;</div>
<div><span class="text-gray-500">Габариты:</span> {{ bbox }}</div>
<div>
<span class="text-gray-500">Водонепр.:</span>
<span :class="result.file_info.is_watertight ? 'text-green-600' : 'text-amber-600'">
{{ result.file_info.is_watertight ? 'Да' : 'Нет' }}
</span>
</div>
</div>
</div>
<!-- Price breakdown -->
<div class="space-y-2.5 mb-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Материал ({{ result.calculation.material.name }}, {{ result.calculation.weight_grams }} г)</span>
<span class="font-medium">{{ fmt(result.calculation.material_cost_rub) }} &#8381;</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Время печати (~{{ result.calculation.print_time_hours }} ч)</span>
<span class="font-medium">{{ fmt(result.calculation.time_cost_rub) }} &#8381;</span>
</div>
<div v-if="result.calculation.post_processing_cost_rub > 0" class="flex justify-between text-sm">
<span class="text-gray-600">Постобработка</span>
<span class="font-medium">{{ fmt(result.calculation.post_processing_cost_rub) }} &#8381;</span>
</div>
<div class="flex justify-between text-sm border-t border-gray-100 pt-2.5">
<span class="text-gray-600">Подитог (1 шт)</span>
<span class="font-medium">{{ fmt(result.calculation.subtotal_rub) }} &#8381;</span>
</div>
<div v-if="result.calculation.quantity > 1" class="flex justify-between text-sm">
<span class="text-gray-600">Количество: {{ result.calculation.quantity }} шт</span>
<span v-if="result.calculation.quantity_discount_percent" class="text-green-600 font-medium">
-{{ result.calculation.quantity_discount_percent }}% скидка
</span>
</div>
</div>
<!-- Total -->
<div class="rounded-lg bg-primary-50 p-4 mb-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-primary-700">Итого</p>
<p class="text-xs text-primary-600">Срок: ~{{ result.calculation.estimated_days }} рабочих дней</p>
</div>
<p class="text-2xl font-bold text-primary-700">{{ fmt(result.calculation.total_rub) }} &#8381;</p>
</div>
</div>
<router-link :to="`/order/${result.calculation_id}`" class="btn-primary w-full text-center block">
Оформить заказ
</router-link>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useCalculatorStore } from '../stores/calculator'
const store = useCalculatorStore()
const result = computed(() => store.result)
const bbox = computed(() => {
if (!result.value) return ''
const b = result.value.file_info.bounding_box_mm
return `${b.x} x ${b.y} x ${b.z} мм`
})
function fmt(n) {
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(n)
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="card">
<h2 class="mb-4 text-lg font-semibold text-gray-900">3. Параметры печати</h2>
<div class="space-y-5">
<!-- Infill -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700" title="Больше заполнение — прочнее, но тяжелее и дороже">
Заполнение
</label>
<span class="text-sm font-semibold text-primary-600">{{ store.settings.infill_percent }}%</span>
</div>
<input
type="range"
:value="store.settings.infill_percent"
@input="store.settings.infill_percent = +$event.target.value"
min="10" max="100" step="10"
class="w-full accent-primary-600"
/>
<div class="mt-1 flex justify-between text-[10px] text-gray-400">
<span>10%</span><span>50%</span><span>100%</span>
</div>
</div>
<!-- Layer height -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700" title="Меньше слой — лучше качество, но дольше печать">
Высота слоя
</label>
<span class="text-sm font-semibold text-primary-600">{{ store.settings.layer_height_mm }} мм</span>
</div>
<input
type="range"
:value="store.settings.layer_height_mm"
@input="store.settings.layer_height_mm = +parseFloat($event.target.value).toFixed(2)"
min="0.08" max="0.4" step="0.04"
class="w-full accent-primary-600"
/>
<div class="mt-1 flex justify-between text-[10px] text-gray-400">
<span>0.08</span><span>0.2</span><span>0.4</span>
</div>
</div>
<!-- Quantity -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700">Количество</label>
<input
type="number"
:value="store.settings.quantity"
@input="store.settings.quantity = Math.max(1, Math.min(500, +$event.target.value || 1))"
min="1" max="500"
class="input-field w-28"
/>
</div>
<!-- Post-processing -->
<div>
<label class="mb-2.5 block text-sm font-medium text-gray-700">Постобработка</label>
<div class="space-y-2">
<label v-for="pp in postProcessingOptions" :key="pp.value" class="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
:value="pp.value"
:checked="store.settings.post_processing.includes(pp.value)"
@change="togglePP(pp.value)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700">{{ pp.label }}</span>
<span class="text-xs text-gray-400">{{ pp.price }}</span>
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useCalculatorStore } from '../stores/calculator'
const store = useCalculatorStore()
const postProcessingOptions = [
{ value: 'sanding', label: 'Шлифовка', price: '300 ₽/шт' },
{ value: 'painting', label: 'Покраска', price: '500 ₽/шт' },
{ value: 'threading', label: 'Нарезка резьбы', price: '200 ₽/шт' },
{ value: 'acetone_smoothing', label: 'Ацетоновая обработка (ABS)', price: '400 ₽/шт' },
]
function togglePP(value) {
const idx = store.settings.post_processing.indexOf(value)
if (idx > -1) {
store.settings.post_processing.splice(idx, 1)
} else {
store.settings.post_processing.push(value)
}
store.result = null
}
</script>

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import CalculatorView from '../views/CalculatorView.vue'
import MaterialsView from '../views/MaterialsView.vue'
import OrderView from '../views/OrderView.vue'
const routes = [
{ path: '/', name: 'calculator', component: CalculatorView },
{ path: '/materials', name: 'materials', component: MaterialsView },
{ path: '/order/:calcId', name: 'order', component: OrderView },
]
export default createRouter({
history: createWebHistory(),
routes,
})

View File

@@ -0,0 +1,63 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import api from '../api/client'
export const useCalculatorStore = defineStore('calculator', () => {
const file = ref(null)
const materialId = ref(null)
const settings = reactive({
infill_percent: 30,
layer_height_mm: 0.2,
quantity: 1,
post_processing: [],
})
const result = ref(null)
const loading = ref(false)
const error = ref(null)
const uploadProgress = ref(0)
async function calculate() {
if (!file.value || !materialId.value) return
loading.value = true
error.value = null
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', file.value)
formData.append('material_id', materialId.value)
formData.append('infill_percent', settings.infill_percent)
formData.append('layer_height_mm', settings.layer_height_mm)
formData.append('quantity', settings.quantity)
formData.append('post_processing', settings.post_processing.join(','))
try {
const { data } = await api.post('/calculate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
},
})
result.value = data
} catch (e) {
error.value = e.response?.data?.detail || 'Ошибка расчёта'
result.value = null
} finally {
loading.value = false
}
}
function reset() {
file.value = null
materialId.value = null
settings.infill_percent = 30
settings.layer_height_mm = 0.2
settings.quantity = 1
settings.post_processing = []
result.value = null
error.value = null
uploadProgress.value = 0
}
return { file, materialId, settings, result, loading, error, uploadProgress, calculate, reset }
})

View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/client'
export const useMaterialsStore = defineStore('materials', () => {
const materials = ref([])
const loading = ref(false)
async function fetchMaterials() {
if (materials.value.length > 0) return
loading.value = true
try {
const { data } = await api.get('/materials')
materials.value = data
} finally {
loading.value = false
}
}
function getMaterialById(id) {
return materials.value.find((m) => m.id === id)
}
const categories = {
basic: 'Базовые',
engineering: 'Инженерные',
composite: 'Композитные',
}
return { materials, loading, fetchMaterials, getMaterialById, categories }
})

View File

@@ -0,0 +1,58 @@
<template>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Калькулятор 3D-печати</h1>
<p class="mt-1 text-sm text-gray-500">Загрузите модель, выберите материал и получите мгновенный расчёт стоимости</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<FileUploader />
<MaterialPicker @open-advisor="showAdvisor = true" />
<PrintSettings />
<div>
<button
@click="store.calculate()"
:disabled="!store.file || !store.materialId || store.loading"
class="btn-primary w-full sm:w-auto"
>
<svg v-if="store.loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ store.loading ? 'Считаем...' : 'Рассчитать стоимость' }}
</button>
<p v-if="store.error" class="mt-2 text-sm text-red-600">{{ store.error }}</p>
</div>
</div>
<div class="lg:col-span-1">
<div class="sticky top-8">
<PriceResult />
<div v-if="!store.result" class="card text-center text-sm text-gray-400">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25v-.008zm2.25-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm0 2.25h.008v.008H12.75v-.008zm2.25-4.5h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008zm0 2.25h.008v.008H15v-.008z" />
</svg>
<p>Загрузите модель и выберите материал для расчёта</p>
</div>
</div>
</div>
</div>
<AiAdvisor :open="showAdvisor" @close="showAdvisor = false" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useCalculatorStore } from '../stores/calculator'
import FileUploader from '../components/FileUploader.vue'
import MaterialPicker from '../components/MaterialPicker.vue'
import PrintSettings from '../components/PrintSettings.vue'
import PriceResult from '../components/PriceResult.vue'
import AiAdvisor from '../components/AiAdvisor.vue'
const store = useCalculatorStore()
const showAdvisor = ref(false)
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Каталог материалов</h1>
<p class="mt-1 text-sm text-gray-500">Все доступные материалы для 3D-печати</p>
</div>
<div v-if="materialsStore.loading" class="text-center py-12 text-gray-500">Загрузка...</div>
<div v-else v-for="(label, cat) in materialsStore.categories" :key="cat" class="mb-8">
<h2 class="mb-4 text-lg font-semibold text-gray-900">{{ label }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="mat in byCategory(cat)" :key="mat.id" class="card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-bold text-gray-900">{{ mat.name }}</h3>
<span class="rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-semibold text-primary-700">
{{ mat.price_per_gram }} &#8381;/г
</span>
</div>
<p class="text-sm text-gray-600 mb-3">{{ mat.description }}</p>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="rounded bg-gray-50 p-2">
<span class="text-gray-500">Темп.</span>
<span class="ml-1 font-medium">{{ mat.properties.min_temp_c }}..{{ mat.properties.max_temp_c }}&deg;C</span>
</div>
<div class="rounded bg-gray-50 p-2">
<span class="text-gray-500">Прочность</span>
<span class="ml-1 font-medium">{{ mat.properties.strength }}</span>
</div>
<div class="rounded bg-gray-50 p-2">
<span class="text-gray-500">Гибкость</span>
<span class="ml-1 font-medium">{{ mat.properties.flexibility }}</span>
</div>
<div class="rounded bg-gray-50 p-2">
<span class="text-gray-500">Хим. стойк.</span>
<span class="ml-1 font-medium">{{ mat.properties.chemical_resistance }}</span>
</div>
</div>
<div v-if="mat.color_options?.length" class="mt-3 flex flex-wrap gap-1.5">
<span v-for="c in mat.color_options" :key="c" class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-600">
{{ c }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useMaterialsStore } from '../stores/materials'
const materialsStore = useMaterialsStore()
onMounted(() => materialsStore.fetchMaterials())
function byCategory(cat) {
return materialsStore.materials.filter((m) => m.category === cat)
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="mx-auto max-w-lg">
<div class="mb-6">
<router-link to="/" class="mb-4 inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Назад к калькулятору
</router-link>
<h1 class="text-2xl font-bold text-gray-900">Оформление заказа</h1>
</div>
<div v-if="!orderResult" class="card">
<OrderForm :calculation-id="calcId" @success="onOrderSuccess" />
</div>
<div v-else class="card text-center">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg class="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">Заказ оформлен!</h2>
<p class="text-sm text-gray-600 mb-1">Номер заказа: <span class="font-semibold">{{ orderResult.order_id }}</span></p>
<p class="text-sm text-gray-600 mb-1">Сумма: <span class="font-semibold">{{ orderResult.total_rub }} &#8381;</span></p>
<p class="text-sm text-gray-600 mb-4">Готовность: <span class="font-semibold">{{ orderResult.estimated_ready_date }}</span></p>
<p class="text-xs text-gray-400 mb-4">Мы свяжемся с вами для подтверждения заказа</p>
<router-link to="/" class="btn-primary">Новый расчёт</router-link>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import OrderForm from '../components/OrderForm.vue'
const route = useRoute()
const calcId = route.params.calcId
const orderResult = ref(null)
function onOrderSuccess(data) {
orderResult.value = data
}
</script>

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
},
},
plugins: [],
}

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
},
})

37
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
events {
worker_connections 1024;
}
http {
client_max_body_size 55M;
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:5173;
}
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}