init
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
uploads/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
787
CLAUDE.md
Normal 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
16
backend/Dockerfile
Normal 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
36
backend/alembic.ini
Normal 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
49
backend/alembic/env.py
Normal 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()
|
||||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal 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
0
backend/app/__init__.py
Normal file
21
backend/app/config.py
Normal file
21
backend/app/config.py
Normal 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
24
backend/app/database.py
Normal 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
90
backend/app/main.py
Normal 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"}
|
||||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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"]
|
||||||
34
backend/app/models/calculation.py
Normal file
34
backend/app/models/calculation.py
Normal 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())
|
||||||
28
backend/app/models/material.py
Normal file
28
backend/app/models/material.py
Normal 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())
|
||||||
25
backend/app/models/order.py
Normal file
25
backend/app/models/order.py
Normal 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())
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
76
backend/app/routers/ai_advisor.py
Normal file
76
backend/app/routers/ai_advisor.py
Normal 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
|
||||||
200
backend/app/routers/calculate.py
Normal file
200
backend/app/routers/calculate.py
Normal 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
52
backend/app/routers/materials.py
Normal file
52
backend/app/routers/materials.py
Normal 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
|
||||||
106
backend/app/routers/orders.py
Normal file
106
backend/app/routers/orders.py
Normal 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"),
|
||||||
|
)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
65
backend/app/schemas/calculate.py
Normal file
65
backend/app/schemas/calculate.py
Normal 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] = []
|
||||||
25
backend/app/schemas/material.py
Normal file
25
backend/app/schemas/material.py
Normal 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}
|
||||||
18
backend/app/schemas/order.py
Normal file
18
backend/app/schemas/order.py
Normal 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
|
||||||
0
backend/app/seed/__init__.py
Normal file
0
backend/app/seed/__init__.py
Normal file
114
backend/app/seed/materials.py
Normal file
114
backend/app/seed/materials.py
Normal 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"],
|
||||||
|
},
|
||||||
|
]
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
104
backend/app/services/ai_advisor.py
Normal file
104
backend/app/services/ai_advisor.py
Normal 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")
|
||||||
62
backend/app/services/file_parser.py
Normal file
62
backend/app/services/file_parser.py
Normal 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,
|
||||||
|
)
|
||||||
140
backend/app/services/price_engine.py
Normal file
140
backend/app/services/price_engine.py
Normal 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,
|
||||||
|
)
|
||||||
67
backend/app/services/storage.py
Normal file
67
backend/app/services/storage.py
Normal 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)
|
||||||
48
backend/app/services/telegram_notify.py
Normal file
48
backend/app/services/telegram_notify.py
Normal 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
13
backend/requirements.txt
Normal 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
20
docker-compose.yml
Normal 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
|
||||||
1
frontend/.env.development
Normal file
1
frontend/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8091/api
|
||||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
2727
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
35
frontend/src/App.vue
Normal file
35
frontend/src/App.vue
Normal 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>
|
||||||
8
frontend/src/api/client.js
Normal file
8
frontend/src/api/client.js
Normal 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
|
||||||
27
frontend/src/assets/styles/main.css
Normal file
27
frontend/src/assets/styles/main.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
frontend/src/components/AiAdvisor.vue
Normal file
132
frontend/src/components/AiAdvisor.vue
Normal 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>
|
||||||
125
frontend/src/components/FileUploader.vue
Normal file
125
frontend/src/components/FileUploader.vue
Normal 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) }} · {{ 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>
|
||||||
82
frontend/src/components/MaterialPicker.vue
Normal file
82
frontend/src/components/MaterialPicker.vue
Normal 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 }} ₽/г</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 }}°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>
|
||||||
77
frontend/src/components/OrderForm.vue
Normal file
77
frontend/src/components/OrderForm.vue
Normal 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>
|
||||||
80
frontend/src/components/PriceResult.vue
Normal file
80
frontend/src/components/PriceResult.vue
Normal 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 }} см³</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) }} ₽</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) }} ₽</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) }} ₽</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) }} ₽</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) }} ₽</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>
|
||||||
100
frontend/src/components/PrintSettings.vue
Normal file
100
frontend/src/components/PrintSettings.vue
Normal 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
10
frontend/src/main.js
Normal 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')
|
||||||
15
frontend/src/router/index.js
Normal file
15
frontend/src/router/index.js
Normal 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,
|
||||||
|
})
|
||||||
63
frontend/src/stores/calculator.js
Normal file
63
frontend/src/stores/calculator.js
Normal 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 }
|
||||||
|
})
|
||||||
31
frontend/src/stores/materials.js
Normal file
31
frontend/src/stores/materials.js
Normal 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 }
|
||||||
|
})
|
||||||
58
frontend/src/views/CalculatorView.vue
Normal file
58
frontend/src/views/CalculatorView.vue
Normal 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>
|
||||||
62
frontend/src/views/MaterialsView.vue
Normal file
62
frontend/src/views/MaterialsView.vue
Normal 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 }} ₽/г
|
||||||
|
</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 }}°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>
|
||||||
45
frontend/src/views/OrderView.vue
Normal file
45
frontend/src/views/OrderView.vue
Normal 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 }} ₽</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>
|
||||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal 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
10
frontend/vite.config.js
Normal 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
37
nginx/nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user