Compare commits
2 Commits
800aaafb24
...
25178247e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25178247e7 | ||
|
|
99e5edd535 |
193
CALCULATOR_GUIDE.md
Normal file
193
CALCULATOR_GUIDE.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Руководство по добавлению нового калькулятора
|
||||||
|
|
||||||
|
Это руководство описывает, как добавить новый калькулятор в систему DoSoapCalc.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Система использует модульную архитектуру с регистрацией калькуляторов:
|
||||||
|
- Каждый калькулятор — отдельный модуль с формулой, схемой полей и шаблоном сообщения
|
||||||
|
- Общие компоненты автоматически генерируют формы и обрабатывают запросы
|
||||||
|
- Динамическая маршрутизация на основе типа калькулятора
|
||||||
|
|
||||||
|
## Шаги добавления нового калькулятора
|
||||||
|
|
||||||
|
### 1. Создание модуля калькулятора на Backend
|
||||||
|
|
||||||
|
Создайте файл `backend/calculators/[calculator-name].js` (например, `candle.js`).
|
||||||
|
|
||||||
|
#### Структура модуля:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// calculators/[calculator-name].js
|
||||||
|
const fieldSchema = [
|
||||||
|
{ id: 'itemName', name: 'itemName', label: 'Название изделия', type: 'text', required: true },
|
||||||
|
{ id: 'weight', name: 'weight', label: 'Вес, г', type: 'number', required: true },
|
||||||
|
// ... другие поля
|
||||||
|
];
|
||||||
|
|
||||||
|
function calculate(data) {
|
||||||
|
// Ваша формула расчёта
|
||||||
|
const total = /* вычисления */;
|
||||||
|
return {
|
||||||
|
// промежуточные результаты
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(data, result) {
|
||||||
|
// Шаблон сообщения для Telegram
|
||||||
|
let text = `...`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredFields() {
|
||||||
|
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumericFields() {
|
||||||
|
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'calculator-name', // уникальный ID
|
||||||
|
name: 'Название калькулятора',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
formatMessage,
|
||||||
|
getRequiredFields,
|
||||||
|
getNumericFields,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Пример поля:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 'fieldId', // уникальный ID поля
|
||||||
|
name: 'fieldName', // имя поля (используется в данных)
|
||||||
|
label: 'Название поля', // метка для пользователя
|
||||||
|
type: 'text' | 'number', // тип поля
|
||||||
|
required: true | false, // обязательное ли поле
|
||||||
|
group: 'packaging', // (опционально) группа для группировки полей
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Регистрация на Backend
|
||||||
|
|
||||||
|
Откройте `backend/calculators/index.js` и добавьте калькулятор:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const newCalculator = require('./[calculator-name]');
|
||||||
|
|
||||||
|
const calculators = {
|
||||||
|
soap: soapCalculator,
|
||||||
|
'[calculator-name]': newCalculator, // добавьте здесь
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Создание модуля калькулятора на Frontend
|
||||||
|
|
||||||
|
Создайте файл `frontend/lib/calculators/[calculator-name].ts`.
|
||||||
|
|
||||||
|
#### Структура модуля:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/calculators/[calculator-name].ts
|
||||||
|
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||||
|
|
||||||
|
const fieldSchema: CalculatorField[] = [
|
||||||
|
{ id: 'itemName', name: 'itemName', label: 'Название изделия', type: 'text', required: true },
|
||||||
|
// ... другие поля (синхронно с backend!)
|
||||||
|
];
|
||||||
|
|
||||||
|
function calculate(data: Record<string, any>): CalculationResult {
|
||||||
|
// Та же формула, что и на backend
|
||||||
|
return {
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCalculator: Calculator = {
|
||||||
|
id: '[calculator-name]',
|
||||||
|
name: 'Название калькулятора',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
getRequiredFields: () => fieldSchema.filter((f) => f.required).map((f) => f.name),
|
||||||
|
getNumericFields: () => fieldSchema.filter((f) => f.type === 'number').map((f) => f.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default newCalculator;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Схема полей и формула должны совпадать с backend!
|
||||||
|
|
||||||
|
### 4. Регистрация на Frontend
|
||||||
|
|
||||||
|
Откройте `frontend/lib/calculators/index.ts` и добавьте:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import newCalculator from './[calculator-name]';
|
||||||
|
|
||||||
|
const calculators: Record<string, Calculator> = {
|
||||||
|
soap: soapCalculator,
|
||||||
|
'[calculator-name]': newCalculator, // добавьте здесь
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Проверка работы
|
||||||
|
|
||||||
|
1. Перезапустите backend: `node bot.js`
|
||||||
|
2. Пересоберите frontend: `npm run build`
|
||||||
|
3. Откройте в браузере: `/[calculator-name]?chat_id=YOUR_CHAT_ID`
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
### Простой калькулятор (только базовые поля)
|
||||||
|
|
||||||
|
См. файлы:
|
||||||
|
- `backend/calculators/candle.js` - пример калькулятора свечей
|
||||||
|
- `frontend/lib/calculators/candle.ts` - frontend версия
|
||||||
|
|
||||||
|
### Калькулятор с группированными полями
|
||||||
|
|
||||||
|
Для группировки полей используйте параметр `group`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ id: 'box', name: 'box', label: 'Пакет', type: 'number', group: 'packaging' },
|
||||||
|
{ id: 'ribbon', name: 'ribbon', label: 'Лента', type: 'number', group: 'packaging' },
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля с одинаковой группой будут автоматически сгруппированы в форме.
|
||||||
|
|
||||||
|
## Советы
|
||||||
|
|
||||||
|
1. **Синхронизация:** Всегда поддерживайте синхронизацию между backend и frontend схемами
|
||||||
|
2. **Валидация:** Используйте `required: true` для обязательных полей
|
||||||
|
3. **Форматирование:** В `formatMessage` используйте HTML для форматирования (поддерживается Telegram)
|
||||||
|
4. **Округление:** Используйте функцию `round()` для округления результатов
|
||||||
|
|
||||||
|
## Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── calculators/
|
||||||
|
│ ├── index.js # Регистрация калькуляторов
|
||||||
|
│ ├── soap.js # Пример: калькулятор мыла
|
||||||
|
│ └── [new-calculator].js # Ваш новый калькулятор
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── lib/
|
||||||
|
│ └── calculators/
|
||||||
|
│ ├── index.ts # Регистрация калькуляторов
|
||||||
|
│ ├── soap.ts # Пример: калькулятор мыла
|
||||||
|
│ └── [new-calculator].ts # Ваш новый калькулятор
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что дальше?
|
||||||
|
|
||||||
|
После добавления калькулятора:
|
||||||
|
1. Протестируйте расчёты вручную
|
||||||
|
2. Проверьте отправку в Telegram
|
||||||
|
3. Убедитесь, что валидация работает корректно
|
||||||
|
4. Добавьте обработку специфичных данных (если нужно)
|
||||||
|
|
||||||
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Все значимые изменения в проекте DoSoapCalc документируются в этом файле.
|
||||||
|
|
||||||
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/),
|
||||||
|
и проект следует [Semantic Versioning](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [2.0.0] - 2024-11-01
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- **Модульная архитектура калькуляторов**: Система поддержки множества калькуляторов с единой логикой
|
||||||
|
- **Система регистрации калькуляторов**: Плагинная архитектура для backend и frontend
|
||||||
|
- **Универсальный компонент DynamicCalculator**: Автоматическая генерация форм из схемы полей
|
||||||
|
- **Пример нового калькулятора**: Калькулятор свечей (candle) как пример добавления новых калькуляторов
|
||||||
|
- **Динамические роуты**: Поддержка `/[calculatorType]` для любых зарегистрированных калькуляторов
|
||||||
|
- **Переменные окружения**: Использование `.env` файлов вместо хардкода
|
||||||
|
- **Валидация данных**: Проверка обязательных и числовых полей на backend
|
||||||
|
- **Улучшенная обработка ошибок**: Детальное логирование и обработка ошибок Telegram API
|
||||||
|
- **PM2 конфигурация**: Файл `ecosystem.config.js` для управления процессами
|
||||||
|
- **Конфигурация CORS**: Настройка разрешённых доменов через переменные окружения
|
||||||
|
- **Документация**:
|
||||||
|
- `PROJECT_INFO.md` - сводная информация о проекте для быстрого понимания
|
||||||
|
- `CALCULATOR_GUIDE.md` - руководство по добавлению новых калькуляторов
|
||||||
|
- `README.md` - обновлённая документация проекта
|
||||||
|
- `CHANGELOG.md` - этот файл для отслеживания изменений
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
|
||||||
|
- **Рефакторинг backend**: Разделение на модули (calculators, lib, routes, config)
|
||||||
|
- **Рефакторинг frontend**: Переход на модульную систему с TypeScript типами
|
||||||
|
- **API маршруты**: Изменён с `/api/submit` на `/api/submit/:calculatorType`
|
||||||
|
- **Компонент SoapCalculator**: Заменён на универсальный DynamicCalculator
|
||||||
|
- **Структура проекта**: Реорганизована для масштабируемости
|
||||||
|
|
||||||
|
### Улучшено
|
||||||
|
|
||||||
|
- **Безопасность**: Токены и URL вынесены в переменные окружения
|
||||||
|
- **Масштабируемость**: Легко добавлять новые калькуляторы без изменения основной логики
|
||||||
|
- **Типизация**: Добавлены TypeScript типы для калькуляторов
|
||||||
|
- **Код-организация**: Разделение ответственности между модулями
|
||||||
|
|
||||||
|
### Технические детали
|
||||||
|
|
||||||
|
#### Backend структура:
|
||||||
|
- `backend/config/env.js` - загрузка переменных окружения
|
||||||
|
- `backend/lib/telegram.js` - общая логика отправки в Telegram
|
||||||
|
- `backend/lib/validator.js` - валидация данных
|
||||||
|
- `backend/calculators/` - модули калькуляторов
|
||||||
|
- `backend/routes/api.js` - динамические API маршруты
|
||||||
|
|
||||||
|
#### Frontend структура:
|
||||||
|
- `frontend/types/calculator.ts` - TypeScript типы
|
||||||
|
- `frontend/lib/calculators/` - модули калькуляторов (frontend)
|
||||||
|
- `frontend/components/DynamicCalculator.tsx` - универсальный компонент
|
||||||
|
- `frontend/app/[calculator]/page.tsx` - динамические роуты
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
|
||||||
|
- **Добавлено**: `dotenv@^16.4.5` для работы с переменными окружения
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-10-XX
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- Базовый функционал калькулятора мыла
|
||||||
|
- Интеграция с Telegram ботом
|
||||||
|
- Веб-интерфейс на Next.js
|
||||||
|
- Отправка результатов расчёта в Telegram
|
||||||
|
- Поддержка загрузки фото
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[2.0.0]: https://github.com/yourusername/DoSoapCalc/compare/v1.0.0...v2.0.0
|
||||||
|
[1.0.0]: https://github.com/yourusername/DoSoapCalc/releases/tag/v1.0.0
|
||||||
|
|
||||||
242
PROJECT_INFO.md
Normal file
242
PROJECT_INFO.md
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
# DoSoapCalc - Сводная информация о проекте
|
||||||
|
|
||||||
|
## Быстрый обзор
|
||||||
|
|
||||||
|
DoSoapCalc - система для расчёта себестоимости изделий ручной работы с веб-интерфейсом и интеграцией Telegram-бота. Проект использует модульную архитектуру для легкого добавления новых калькуляторов.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
DoSoapCalc/
|
||||||
|
├── backend/ # Node.js + Express + Telegram Bot
|
||||||
|
│ ├── bot.js # Главный файл - запускает бота и сервер
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── env.js # Загрузка переменных окружения (.env)
|
||||||
|
│ ├── calculators/ # Модули калькуляторов
|
||||||
|
│ │ ├── index.js # Регистрация всех калькуляторов
|
||||||
|
│ │ ├── soap.js # Калькулятор мыла
|
||||||
|
│ │ └── candle.js # Пример: калькулятор свечей
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── telegram.js # Отправка сообщений в Telegram
|
||||||
|
│ │ └── validator.js # Валидация данных форм
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.js # API маршруты (/api/submit/:calculatorType)
|
||||||
|
│ └── .env # Переменные окружения (не в Git)
|
||||||
|
│
|
||||||
|
└── frontend/ # Next.js + React + TypeScript
|
||||||
|
├── app/
|
||||||
|
│ ├── [calculator]/ # Динамический роут для калькуляторов
|
||||||
|
│ │ └── page.tsx # Страница калькулятора
|
||||||
|
│ └── page.tsx # Редирект на /soap
|
||||||
|
├── components/
|
||||||
|
│ ├── DynamicCalculator.tsx # Универсальный компонент формы
|
||||||
|
│ └── FormField.tsx # Переиспользуемые поля ввода
|
||||||
|
├── lib/
|
||||||
|
│ ├── calculators/ # Модули калькуляторов (frontend)
|
||||||
|
│ │ ├── index.ts # Регистрация калькуляторов
|
||||||
|
│ │ ├── soap.ts # Калькулятор мыла
|
||||||
|
│ │ └── candle.ts # Пример: калькулятор свечей
|
||||||
|
│ ├── api.ts # Отправка данных на backend
|
||||||
|
│ └── config.ts # Конфигурация (API URL)
|
||||||
|
└── types/
|
||||||
|
└── calculator.ts # TypeScript типы
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
### Backend:
|
||||||
|
- Node.js + Express
|
||||||
|
- node-telegram-bot-api (Telegram Bot)
|
||||||
|
- multer (загрузка файлов)
|
||||||
|
- dotenv (переменные окружения)
|
||||||
|
- PM2 (менеджер процессов)
|
||||||
|
|
||||||
|
### Frontend:
|
||||||
|
- Next.js 15 (App Router)
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS 4
|
||||||
|
- Статический экспорт (`output: 'export'`)
|
||||||
|
|
||||||
|
## Ключевые концепции
|
||||||
|
|
||||||
|
### Система калькуляторов
|
||||||
|
|
||||||
|
Каждый калькулятор состоит из двух модулей (backend + frontend):
|
||||||
|
|
||||||
|
**Backend модуль** (`backend/calculators/[name].js`):
|
||||||
|
- `id` - уникальный идентификатор
|
||||||
|
- `name` - название для пользователя
|
||||||
|
- `fieldSchema` - схема полей формы
|
||||||
|
- `calculate(data)` - функция расчёта
|
||||||
|
- `formatMessage(data, result)` - форматирование для Telegram
|
||||||
|
- `getRequiredFields()` - обязательные поля
|
||||||
|
- `getNumericFields()` - числовые поля
|
||||||
|
|
||||||
|
**Frontend модуль** (`frontend/lib/calculators/[name].ts`):
|
||||||
|
- Та же структура, синхронная с backend
|
||||||
|
- TypeScript типы
|
||||||
|
|
||||||
|
### Регистрация калькуляторов
|
||||||
|
|
||||||
|
**Backend**: `backend/calculators/index.js`
|
||||||
|
```javascript
|
||||||
|
const calculators = {
|
||||||
|
soap: require('./soap'),
|
||||||
|
candle: require('./candle'),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend**: `frontend/lib/calculators/index.ts`
|
||||||
|
```typescript
|
||||||
|
const calculators: Record<string, Calculator> = {
|
||||||
|
soap: soapCalculator,
|
||||||
|
candle: candleCalculator,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
- `POST /api/submit/:calculatorType` - отправка расчёта
|
||||||
|
- Параметры: `calculatorType` (soap, candle, etc.)
|
||||||
|
- Body: FormData (поля формы + опционально photo)
|
||||||
|
- Ответ: 200 OK или ошибка
|
||||||
|
|
||||||
|
### Telegram Bot
|
||||||
|
|
||||||
|
- Команда `/menu` - открывает Web App с калькулятором
|
||||||
|
- URL формата: `{WEBAPP_BASE_URL}/{calculatorType}?chat_id={chatId}`
|
||||||
|
- Отправка результатов через `sendMessage` или `sendPhoto`
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Backend (.env в `/backend/`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=your_telegram_bot_token
|
||||||
|
WEBAPP_BASE_URL=https://your-domain.com
|
||||||
|
API_BASE_URL=https://api.your-domain.com
|
||||||
|
HTTP_PORT=3001
|
||||||
|
CORS_ORIGINS=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (.env.local в `/frontend/`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://api.your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск проекта
|
||||||
|
|
||||||
|
### PM2 (Production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production режим (через node)
|
||||||
|
pm2 start ecosystem.config.js --env production
|
||||||
|
|
||||||
|
# Development режим (через nodemon с автоперезагрузкой)
|
||||||
|
pm2 start ecosystem.config.js --env development
|
||||||
|
|
||||||
|
# Управление
|
||||||
|
pm2 restart ecosystem.config.js
|
||||||
|
pm2 stop ecosystem.config.js
|
||||||
|
pm2 logs dosoap-backend
|
||||||
|
pm2 logs dosoap-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ручной запуск
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
# Создать .env файл
|
||||||
|
node bot.js
|
||||||
|
# или с nodemon:
|
||||||
|
nodemon bot.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Development
|
||||||
|
npm run build # Production build
|
||||||
|
npm run export # Статический экспорт
|
||||||
|
```
|
||||||
|
|
||||||
|
## Динамические роуты
|
||||||
|
|
||||||
|
- `/soap` - калькулятор мыла
|
||||||
|
- `/candle` - калькулятор свечей
|
||||||
|
- `/[calculatorType]` - любой зарегистрированный калькулятор
|
||||||
|
|
||||||
|
## Добавление нового калькулятора
|
||||||
|
|
||||||
|
1. Создать `backend/calculators/[name].js` с модулем
|
||||||
|
2. Создать `frontend/lib/calculators/[name].ts` (синхронная структура)
|
||||||
|
3. Зарегистрировать в `calculators/index.js` (backend) и `calculators/index.ts` (frontend)
|
||||||
|
4. Перезапустить процессы
|
||||||
|
|
||||||
|
Подробное руководство: `CALCULATOR_GUIDE.md`
|
||||||
|
|
||||||
|
## Файлы конфигурации
|
||||||
|
|
||||||
|
- `ecosystem.config.js` - PM2 конфигурация
|
||||||
|
- `.gitignore` - игнорирует `.env`, но не `.env.example`
|
||||||
|
- `next.config.ts` - Next.js конфигурация (static export)
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
PM2 логи находятся в:
|
||||||
|
- Backend: `~/.pm2/logs/dosoap-backend-*.log`
|
||||||
|
- Frontend: `~/.pm2/logs/dosoap-frontend-*.log`
|
||||||
|
|
||||||
|
## Важные детали
|
||||||
|
|
||||||
|
1. **Синхронизация схем**: Frontend и backend схемы полей должны совпадать
|
||||||
|
2. **Рабочая директория**: Backend должен запускаться из `/backend/` для загрузки `.env`
|
||||||
|
3. **CORS**: Настроен для конкретных доменов через `CORS_ORIGINS`
|
||||||
|
4. **Статический экспорт**: Frontend экспортируется статически (Next.js export)
|
||||||
|
5. **Фото**: Поддерживается загрузка фото, отправляется в Telegram как caption
|
||||||
|
|
||||||
|
## Структура данных калькулятора
|
||||||
|
|
||||||
|
### Поле схемы:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string; // Уникальный ID
|
||||||
|
name: string; // Имя поля в данных
|
||||||
|
label: string; // Метка для пользователя
|
||||||
|
type: 'text' | 'number';
|
||||||
|
required: boolean;
|
||||||
|
group?: string; // Группировка полей (опционально)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Результат расчёта:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
[key: string]: number; // Промежуточные значения
|
||||||
|
total: number; // Итоговая себестоимость (обязательно)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды Telegram бота
|
||||||
|
|
||||||
|
- `/menu` - открыть калькулятор (по умолчанию мыло)
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
- Backend: 3001 (HTTP_PORT из .env)
|
||||||
|
- Frontend: 3000 (в development), статический экспорт (production)
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Токен бота в `.env` (не в Git)
|
||||||
|
- CORS настроен для конкретных доменов
|
||||||
|
- Валидация обязательных и числовых полей на backend
|
||||||
|
- HTML форматирование в Telegram сообщениях (parse_mode: 'HTML')
|
||||||
|
|
||||||
147
README.md
Normal file
147
README.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# DoSoapCalc
|
||||||
|
|
||||||
|
Калькулятор себестоимости изделий ручной работы с отправкой результатов в Telegram.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
DoSoapCalc - это система для расчёта себестоимости различных изделий ручной работы (мыло, свечи и др.) с веб-интерфейсом и интеграцией с Telegram-ботом.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Проект состоит из двух частей:
|
||||||
|
- **Backend** (Node.js + Express) - API сервер и Telegram-бот
|
||||||
|
- **Frontend** (Next.js + React + TypeScript) - веб-интерфейс калькуляторов
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Перейдите в папку `backend`:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Создайте файл `.env` на основе `.env.example`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Заполните переменные окружения в `.env`:
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=your_telegram_bot_token
|
||||||
|
WEBAPP_BASE_URL=https://your-domain.com
|
||||||
|
API_BASE_URL=https://api.your-domain.com
|
||||||
|
HTTP_PORT=3001
|
||||||
|
CORS_ORIGINS=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Запустите сервер:
|
||||||
|
```bash
|
||||||
|
node bot.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. Перейдите в папку `frontend`:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. (Опционально) Создайте файл `.env.local` для переопределения API URL:
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://api.your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Запустите dev-сервер:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Для production сборки:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run export
|
||||||
|
```
|
||||||
|
|
||||||
|
## Добавление новых калькуляторов
|
||||||
|
|
||||||
|
См. подробное руководство в файле [CALCULATOR_GUIDE.md](./CALCULATOR_GUIDE.md)
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
DoSoapCalc/
|
||||||
|
├── backend/
|
||||||
|
│ ├── calculators/ # Модули калькуляторов
|
||||||
|
│ │ ├── index.js # Регистрация калькуляторов
|
||||||
|
│ │ ├── soap.js # Калькулятор мыла
|
||||||
|
│ │ └── candle.js # Пример: калькулятор свечей
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── env.js # Загрузка переменных окружения
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── telegram.js # Логика отправки в Telegram
|
||||||
|
│ │ └── validator.js # Валидация данных
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.js # API маршруты
|
||||||
|
│ └── bot.js # Главный файл backend
|
||||||
|
│
|
||||||
|
└── frontend/
|
||||||
|
├── app/
|
||||||
|
│ ├── [calculator]/ # Динамический роут для калькуляторов
|
||||||
|
│ └── page.tsx # Редирект на /soap
|
||||||
|
├── components/
|
||||||
|
│ ├── DynamicCalculator.tsx # Универсальный компонент
|
||||||
|
│ └── FormField.tsx # Переиспользуемые поля
|
||||||
|
├── lib/
|
||||||
|
│ ├── calculators/ # Модули калькуляторов (frontend)
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── soap.ts
|
||||||
|
│ │ └── candle.ts
|
||||||
|
│ ├── api.ts # Логика отправки данных
|
||||||
|
│ └── config.ts # Конфигурация
|
||||||
|
└── types/
|
||||||
|
└── calculator.ts # TypeScript типы
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Откройте Telegram-бота
|
||||||
|
2. Отправьте команду `/menu`
|
||||||
|
3. Нажмите на кнопку для открытия калькулятора
|
||||||
|
4. Заполните форму
|
||||||
|
5. Нажмите "Отправить расчёт в Telegram"
|
||||||
|
6. Результаты будут отправлены в чат с ботом
|
||||||
|
|
||||||
|
## Доступные калькуляторы
|
||||||
|
|
||||||
|
- **Мыло** (`/soap`) - расчёт себестоимости мыла ручной работы
|
||||||
|
- **Свеча** (`/candle`) - пример калькулятора свечей
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Node.js
|
||||||
|
- Express
|
||||||
|
- node-telegram-bot-api
|
||||||
|
- multer (загрузка файлов)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Next.js 15
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS 4
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|
||||||
134
backend/bot.js
134
backend/bot.js
@ -14,25 +14,30 @@ const bodyParser = require('body-parser');
|
|||||||
const TelegramBot = require('node-telegram-bot-api');
|
const TelegramBot = require('node-telegram-bot-api');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// Добавляем multer и streamifier
|
|
||||||
const multer = require('multer');
|
// Загружаем переменные окружения
|
||||||
const streamifier = require('streamifier');
|
const config = require('./config/env');
|
||||||
|
|
||||||
|
// Импортируем маршруты API
|
||||||
|
const apiRoutes = require('./routes/api');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const HTTP_PORT = 3001; // порт для BACKEND API
|
const HTTP_PORT = config.HTTP_PORT;
|
||||||
const BOT_TOKEN = '7801636590:AAFphqOK0Dqta7v9VCLkTPGYC1OujNIFgXA'; // ← замените на свой токен
|
const BOT_TOKEN = config.BOT_TOKEN;
|
||||||
const WEBAPP_BASE_URL = 'https://dosoap.duckdns.org'; // ← например: https://xyz123.ngrok.io
|
const WEBAPP_BASE_URL = config.WEBAPP_BASE_URL;
|
||||||
|
|
||||||
// 1) Запускаем бота (polling)
|
// 1) Запускаем бота (polling)
|
||||||
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||||
|
|
||||||
// 2) Настраиваем multer (храним файлы в памяти)
|
// Сохраняем бота в app.locals для использования в роутах
|
||||||
const storage = multer.memoryStorage();
|
app.locals.bot = bot;
|
||||||
const upload = multer({ storage });
|
|
||||||
|
|
||||||
// 3) Разрешаем CORS для фронтенда
|
// 3) Разрешаем CORS для фронтенда
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
const origin = req.headers.origin;
|
||||||
|
if (config.CORS_ORIGINS.includes('*') || (origin && config.CORS_ORIGINS.includes(origin))) {
|
||||||
|
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
}
|
||||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@ -40,108 +45,8 @@ app.use((req, res, next) => {
|
|||||||
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
// 5) Обработчик POST /api/submit (multipart/form-data)
|
// 5) Подключаем API маршруты
|
||||||
app.post(
|
app.use('/api', apiRoutes);
|
||||||
'/api/submit',
|
|
||||||
upload.single('photo'), // поле "photo" — это файл, если есть
|
|
||||||
(req, res) => {
|
|
||||||
try {
|
|
||||||
// Текстовые поля придут в req.body, файл — в req.file
|
|
||||||
const {
|
|
||||||
chat_id,
|
|
||||||
soapName,
|
|
||||||
weight,
|
|
||||||
basePrice,
|
|
||||||
aromaPrice,
|
|
||||||
aromaWeight,
|
|
||||||
pigmentPrice,
|
|
||||||
pigmentWeight,
|
|
||||||
moldPrice,
|
|
||||||
box,
|
|
||||||
filler,
|
|
||||||
ribbon,
|
|
||||||
labelValue,
|
|
||||||
markup,
|
|
||||||
totalCost,
|
|
||||||
finalPrice,
|
|
||||||
pricePer100g,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
// Проверяем обязательные поля
|
|
||||||
if (!chat_id) {
|
|
||||||
return res.status(400).send('chat_id не передан');
|
|
||||||
}
|
|
||||||
if (!soapName) {
|
|
||||||
return res.status(400).send('soapName не передан');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Соберём сообщение так, чтобы в чат пришло всё, что ввели:
|
|
||||||
// 1. Название мыла
|
|
||||||
// 2. Вес и цена основы
|
|
||||||
// 3. Отдушка
|
|
||||||
// 4. Пигмент
|
|
||||||
// 5. Форма
|
|
||||||
// 6. Упаковка
|
|
||||||
// 7. Наценка
|
|
||||||
// 8. Итоги
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
|
||||||
|
|
||||||
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
|
||||||
// text += `🔹 <b>Цена за 1 кг основы:</b> ${basePrice} ₽/кг\n\n`;
|
|
||||||
|
|
||||||
// text += `🔹 <b>Отдушка:</b> ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`;
|
|
||||||
// text += `🔹 <b>Пигмент:</b> ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`;
|
|
||||||
|
|
||||||
// text += `🔹 <b>Цена формы:</b> ${moldPrice} ₽\n\n`;
|
|
||||||
|
|
||||||
text += `📦 <b>Упаковка:</b>\n`;
|
|
||||||
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
|
||||||
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
|
||||||
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
|
||||||
text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`;
|
|
||||||
|
|
||||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
|
||||||
|
|
||||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
|
||||||
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
|
||||||
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
|
||||||
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
|
||||||
|
|
||||||
|
|
||||||
// Если пользователь прикрепил фото (req.file), шлём sendPhoto
|
|
||||||
if (req.file) {
|
|
||||||
const bufferStream = streamifier.createReadStream(req.file.buffer);
|
|
||||||
|
|
||||||
return bot
|
|
||||||
.sendPhoto(
|
|
||||||
Number(chat_id),
|
|
||||||
bufferStream,
|
|
||||||
{ caption: text, parse_mode: 'HTML' }
|
|
||||||
)
|
|
||||||
.then(() => res.sendStatus(200))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Ошибка при отправке фото:', err);
|
|
||||||
res.status(500).send('Ошибка при отправке фото ботом');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если фото не было, просто шлём текст
|
|
||||||
bot
|
|
||||||
.sendMessage(Number(chat_id), text, { parse_mode: 'HTML' })
|
|
||||||
.then(() => res.sendStatus(200))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Ошибка при отправке сообщения:', err);
|
|
||||||
res.sendStatus(500);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка в /api/submit:', err);
|
|
||||||
res.status(500).send('Внутренняя ошибка сервера');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
||||||
bot.setMyCommands([
|
bot.setMyCommands([
|
||||||
@ -151,7 +56,8 @@ bot.setMyCommands([
|
|||||||
bot.onText(/\/menu/, (msg) => {
|
bot.onText(/\/menu/, (msg) => {
|
||||||
try {
|
try {
|
||||||
const chatId = msg.chat.id;
|
const chatId = msg.chat.id;
|
||||||
const url = `${WEBAPP_BASE_URL}/?chat_id=${chatId}`;
|
// По умолчанию открываем калькулятор мыла
|
||||||
|
const url = `${WEBAPP_BASE_URL}/soap?chat_id=${chatId}`;
|
||||||
|
|
||||||
bot.sendMessage(
|
bot.sendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
@ -161,7 +67,7 @@ bot.onText(/\/menu/, (msg) => {
|
|||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Открыть калькулятор',
|
text: 'Открыть калькулятор мыла',
|
||||||
web_app: { url },
|
web_app: { url },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
114
backend/calculators/candle.js
Normal file
114
backend/calculators/candle.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// calculators/candle.js
|
||||||
|
// Калькулятор свечей (пример нового калькулятора)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Схема полей калькулятора
|
||||||
|
*/
|
||||||
|
const fieldSchema = [
|
||||||
|
{ id: 'candleName', name: 'candleName', label: 'Название свечи', type: 'text', required: true },
|
||||||
|
{ id: 'weight', name: 'weight', label: 'Вес свечи, г', type: 'number', required: true },
|
||||||
|
{ id: 'waxPrice', name: 'waxPrice', label: 'Цена воска за 1 кг, руб', type: 'number', required: false },
|
||||||
|
{ id: 'wickPrice', name: 'wickPrice', label: 'Цена фитиля, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||||
|
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'box', name: 'box', label: 'Упаковка, руб', type: 'number', required: false },
|
||||||
|
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчёт себестоимости свечи
|
||||||
|
* @param {Object} data - Данные для расчёта
|
||||||
|
* @returns {Object} Результаты расчёта
|
||||||
|
*/
|
||||||
|
function calculate(data) {
|
||||||
|
const {
|
||||||
|
weight = 0,
|
||||||
|
waxPrice = 0,
|
||||||
|
wickPrice = 0,
|
||||||
|
aromaPrice = 0,
|
||||||
|
aromaWeight = 1,
|
||||||
|
moldPrice = 0,
|
||||||
|
box = 0,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const wax = (weight / 1000) * waxPrice;
|
||||||
|
const wick = wickPrice;
|
||||||
|
const aroma = ((weight * 0.08) / aromaWeight) * aromaPrice; // 8% отдушки для свечей
|
||||||
|
const mold = moldPrice / 50; // Амортизация формы на 50 свечей
|
||||||
|
const packaging = box;
|
||||||
|
|
||||||
|
const subtotal = wax + wick + aroma + mold + packaging;
|
||||||
|
const operational = subtotal * 0.05;
|
||||||
|
const total = subtotal + operational;
|
||||||
|
|
||||||
|
return {
|
||||||
|
wax: round(wax),
|
||||||
|
wick: round(wick),
|
||||||
|
aroma: round(aroma),
|
||||||
|
mold: round(mold),
|
||||||
|
packaging: round(packaging),
|
||||||
|
operational: round(operational),
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(val) {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует сообщение для Telegram
|
||||||
|
* @param {Object} data - Исходные данные
|
||||||
|
* @param {Object} result - Результаты расчёта
|
||||||
|
* @returns {string} Отформатированное сообщение
|
||||||
|
*/
|
||||||
|
function formatMessage(data, result) {
|
||||||
|
const {
|
||||||
|
candleName,
|
||||||
|
weight = 0,
|
||||||
|
box = 0,
|
||||||
|
markup = 0,
|
||||||
|
totalCost,
|
||||||
|
finalPrice,
|
||||||
|
pricePer100g,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||||
|
text += `⚖️ <b>Вес свечи:</b> ${weight} г\n\n`;
|
||||||
|
text += `📦 <b>Упаковка:</b> ${box} ₽\n\n`;
|
||||||
|
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||||
|
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||||
|
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить обязательные поля для валидации
|
||||||
|
* @returns {string[]} Массив имён обязательных полей
|
||||||
|
*/
|
||||||
|
function getRequiredFields() {
|
||||||
|
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить числовые поля для валидации
|
||||||
|
* @returns {string[]} Массив имён числовых полей
|
||||||
|
*/
|
||||||
|
function getNumericFields() {
|
||||||
|
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'candle',
|
||||||
|
name: 'Свеча',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
formatMessage,
|
||||||
|
getRequiredFields,
|
||||||
|
getNumericFields,
|
||||||
|
};
|
||||||
|
|
||||||
47
backend/calculators/index.js
Normal file
47
backend/calculators/index.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// calculators/index.js
|
||||||
|
// Регистрация всех калькуляторов
|
||||||
|
|
||||||
|
// Регистрация калькуляторов
|
||||||
|
// Ключ - это тип калькулятора (используется в URL и командах)
|
||||||
|
const soapCalculator = require('./soap');
|
||||||
|
const candleCalculator = require('./candle');
|
||||||
|
|
||||||
|
const calculators = {
|
||||||
|
soap: soapCalculator,
|
||||||
|
candle: candleCalculator,
|
||||||
|
// Здесь будут добавляться новые калькуляторы:
|
||||||
|
// bathBomb: bathBombCalculator,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить калькулятор по типу
|
||||||
|
* @param {string} type - Тип калькулятора
|
||||||
|
* @returns {Object|null} Модуль калькулятора или null
|
||||||
|
*/
|
||||||
|
function getCalculator(type) {
|
||||||
|
return calculators[type] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список всех доступных калькуляторов
|
||||||
|
* @returns {string[]} Массив типов калькуляторов
|
||||||
|
*/
|
||||||
|
function getAvailableCalculators() {
|
||||||
|
return Object.keys(calculators);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить, существует ли калькулятор
|
||||||
|
* @param {string} type - Тип калькулятора
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasCalculator(type) {
|
||||||
|
return type in calculators;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCalculator,
|
||||||
|
getAvailableCalculators,
|
||||||
|
hasCalculator,
|
||||||
|
};
|
||||||
|
|
||||||
125
backend/calculators/soap.js
Normal file
125
backend/calculators/soap.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
// calculators/soap.js
|
||||||
|
// Калькулятор мыла
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Схема полей калькулятора
|
||||||
|
*/
|
||||||
|
const fieldSchema = [
|
||||||
|
{ id: 'soapName', name: 'soapName', label: 'Название мыла', type: 'text', required: true },
|
||||||
|
{ id: 'weight', name: 'weight', label: 'Вес мыла, г', type: 'number', required: true },
|
||||||
|
{ id: 'basePrice', name: 'basePrice', label: 'Цена основы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||||
|
{ id: 'pigmentPrice', name: 'pigmentPrice', label: 'Цена пигмента, руб', type: 'number', required: false },
|
||||||
|
{ id: 'pigmentWeight', name: 'pigmentWeight', label: 'Фасовка пигмента, г', type: 'number', required: false },
|
||||||
|
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'box', name: 'box', label: 'Пакет/коробка, руб', type: 'number', required: false },
|
||||||
|
{ id: 'filler', name: 'filler', label: 'Наполнитель, руб', type: 'number', required: false },
|
||||||
|
{ id: 'ribbon', name: 'ribbon', label: 'Лента, руб', type: 'number', required: false },
|
||||||
|
{ id: 'labelValue', name: 'labelValue', label: 'Наклейка, руб', type: 'number', required: false },
|
||||||
|
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчёт себестоимости мыла
|
||||||
|
* @param {Object} data - Данные для расчёта
|
||||||
|
* @returns {Object} Результаты расчёта
|
||||||
|
*/
|
||||||
|
function calculate(data) {
|
||||||
|
const {
|
||||||
|
weight = 0,
|
||||||
|
basePrice = 0,
|
||||||
|
aromaPrice = 0,
|
||||||
|
aromaWeight = 1,
|
||||||
|
pigmentPrice = 0,
|
||||||
|
pigmentWeight = 1,
|
||||||
|
moldPrice = 0,
|
||||||
|
packaging = { box: 0, filler: 0, ribbon: 0, label: 0 },
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const base = (weight / 1000) * basePrice;
|
||||||
|
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||||
|
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||||
|
const mold = moldPrice / 100;
|
||||||
|
const packagingCost = (packaging.box || 0) + (packaging.filler || 0) + (packaging.ribbon || 0) + (packaging.label || 0);
|
||||||
|
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||||
|
const operational = subtotal * 0.05;
|
||||||
|
const total = subtotal + operational;
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: round(base),
|
||||||
|
aroma: round(aroma),
|
||||||
|
pigment: round(pigment),
|
||||||
|
mold: round(mold),
|
||||||
|
packaging: round(packagingCost),
|
||||||
|
operational: round(operational),
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(val) {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует сообщение для Telegram
|
||||||
|
* @param {Object} data - Исходные данные
|
||||||
|
* @param {Object} result - Результаты расчёта
|
||||||
|
* @returns {string} Отформатированное сообщение
|
||||||
|
*/
|
||||||
|
function formatMessage(data, result) {
|
||||||
|
const {
|
||||||
|
soapName,
|
||||||
|
weight = 0,
|
||||||
|
box = 0,
|
||||||
|
filler = 0,
|
||||||
|
ribbon = 0,
|
||||||
|
labelValue = 0,
|
||||||
|
markup = 0,
|
||||||
|
totalCost,
|
||||||
|
finalPrice,
|
||||||
|
pricePer100g,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||||
|
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
||||||
|
text += `📦 <b>Упаковка:</b>\n`;
|
||||||
|
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
||||||
|
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
||||||
|
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
||||||
|
text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`;
|
||||||
|
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||||
|
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||||
|
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить обязательные поля для валидации
|
||||||
|
* @returns {string[]} Массив имён обязательных полей
|
||||||
|
*/
|
||||||
|
function getRequiredFields() {
|
||||||
|
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить числовые поля для валидации
|
||||||
|
* @returns {string[]} Массив имён числовых полей
|
||||||
|
*/
|
||||||
|
function getNumericFields() {
|
||||||
|
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'soap',
|
||||||
|
name: 'Мыло',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
formatMessage,
|
||||||
|
getRequiredFields,
|
||||||
|
getNumericFields,
|
||||||
|
};
|
||||||
|
|
||||||
30
backend/config/env.js
Normal file
30
backend/config/env.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// config/env.js
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const requiredEnvVars = [
|
||||||
|
'BOT_TOKEN',
|
||||||
|
'WEBAPP_BASE_URL',
|
||||||
|
];
|
||||||
|
|
||||||
|
function validateEnv() {
|
||||||
|
const missing = requiredEnvVars.filter((key) => !process.env[key]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Отсутствуют обязательные переменные окружения: ${missing.join(', ')}\n` +
|
||||||
|
'Создайте файл .env на основе .env.example'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateEnv();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BOT_TOKEN: process.env.BOT_TOKEN,
|
||||||
|
WEBAPP_BASE_URL: process.env.WEBAPP_BASE_URL,
|
||||||
|
API_BASE_URL: process.env.API_BASE_URL || process.env.WEBAPP_BASE_URL.replace('dosoap', 'api-dosoap'),
|
||||||
|
HTTP_PORT: parseInt(process.env.HTTP_PORT || '3001', 10),
|
||||||
|
CORS_ORIGINS: process.env.CORS_ORIGINS
|
||||||
|
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
|
||||||
|
: ['*'],
|
||||||
|
};
|
||||||
|
|
||||||
30
backend/lib/telegram.js
Normal file
30
backend/lib/telegram.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// lib/telegram.js
|
||||||
|
// Общая логика для отправки сообщений в Telegram
|
||||||
|
|
||||||
|
const streamifier = require('streamifier');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет сообщение в Telegram (с фото или без)
|
||||||
|
* @param {TelegramBot} bot - Экземпляр бота
|
||||||
|
* @param {number} chatId - ID чата
|
||||||
|
* @param {string} text - Текст сообщения
|
||||||
|
* @param {Buffer|null} photoBuffer - Буфер фото (опционально)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function sendTelegramMessage(bot, chatId, text, photoBuffer = null) {
|
||||||
|
if (photoBuffer) {
|
||||||
|
const bufferStream = streamifier.createReadStream(photoBuffer);
|
||||||
|
await bot.sendPhoto(
|
||||||
|
Number(chatId),
|
||||||
|
bufferStream,
|
||||||
|
{ caption: text, parse_mode: 'HTML' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await bot.sendMessage(Number(chatId), text, { parse_mode: 'HTML' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendTelegramMessage,
|
||||||
|
};
|
||||||
|
|
||||||
47
backend/lib/validator.js
Normal file
47
backend/lib/validator.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// lib/validator.js
|
||||||
|
// Валидация данных для калькуляторов
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует обязательные поля
|
||||||
|
* @param {Object} data - Данные для проверки
|
||||||
|
* @param {string[]} requiredFields - Массив обязательных полей
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateRequiredFields(data, requiredFields) {
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!data[field] || data[field] === '') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Поле "${field}" обязательно для заполнения`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует числовые поля
|
||||||
|
* @param {Object} data - Данные для проверки
|
||||||
|
* @param {string[]} numericFields - Массив полей, которые должны быть числами
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateNumericFields(data, numericFields) {
|
||||||
|
for (const field of numericFields) {
|
||||||
|
if (data[field] !== undefined && data[field] !== null && data[field] !== '') {
|
||||||
|
const num = Number(data[field]);
|
||||||
|
if (isNaN(num) || num < 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Поле "${field}" должно быть положительным числом`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateRequiredFields,
|
||||||
|
validateNumericFields,
|
||||||
|
};
|
||||||
|
|
||||||
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
@ -600,6 +601,17 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
|
|||||||
140
backend/routes/api.js
Normal file
140
backend/routes/api.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// routes/api.js
|
||||||
|
// API маршруты для калькуляторов
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { getCalculator, hasCalculator } = require('../calculators');
|
||||||
|
const { sendTelegramMessage } = require('../lib/telegram');
|
||||||
|
const { validateRequiredFields, validateNumericFields } = require('../lib/validator');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Настраиваем multer (храним файлы в памяти)
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик POST /api/submit/:calculatorType
|
||||||
|
* Принимает данные калькулятора и отправляет результат в Telegram
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/submit/:calculatorType',
|
||||||
|
upload.single('photo'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { calculatorType } = req.params;
|
||||||
|
const { chat_id, ...formData } = req.body;
|
||||||
|
const photoFile = req.file;
|
||||||
|
|
||||||
|
// Проверяем, существует ли калькулятор
|
||||||
|
if (!hasCalculator(calculatorType)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Калькулятор "${calculatorType}" не найден`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем модуль калькулятора
|
||||||
|
const calculator = getCalculator(calculatorType);
|
||||||
|
|
||||||
|
// Валидация обязательных полей
|
||||||
|
const requiredValidation = validateRequiredFields(
|
||||||
|
{ chat_id, ...formData },
|
||||||
|
['chat_id', ...calculator.getRequiredFields()]
|
||||||
|
);
|
||||||
|
if (!requiredValidation.valid) {
|
||||||
|
return res.status(400).json({ error: requiredValidation.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация числовых полей
|
||||||
|
const numericValidation = validateNumericFields(
|
||||||
|
formData,
|
||||||
|
calculator.getNumericFields()
|
||||||
|
);
|
||||||
|
if (!numericValidation.valid) {
|
||||||
|
return res.status(400).json({ error: numericValidation.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготавливаем данные для расчёта
|
||||||
|
const calculationData = prepareCalculationData(formData, calculator.fieldSchema);
|
||||||
|
|
||||||
|
// Выполняем расчёт
|
||||||
|
const calculationResult = calculator.calculate(calculationData);
|
||||||
|
|
||||||
|
// Вычисляем финальную цену и цену за 100г (если применимо)
|
||||||
|
const markup = parseFloat(formData.markup || 0);
|
||||||
|
const totalCost = calculationResult.total;
|
||||||
|
const finalPrice = totalCost * (1 + markup / 100);
|
||||||
|
const weight = parseFloat(formData.weight || 0);
|
||||||
|
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
|
||||||
|
|
||||||
|
// Формируем сообщение для Telegram
|
||||||
|
const messageData = {
|
||||||
|
...formData,
|
||||||
|
totalCost,
|
||||||
|
finalPrice,
|
||||||
|
pricePer100g,
|
||||||
|
};
|
||||||
|
const telegramMessage = calculator.formatMessage(messageData, calculationResult);
|
||||||
|
|
||||||
|
// Отправляем в Telegram
|
||||||
|
try {
|
||||||
|
await sendTelegramMessage(
|
||||||
|
req.app.locals.bot,
|
||||||
|
chat_id,
|
||||||
|
telegramMessage,
|
||||||
|
photoFile ? photoFile.buffer : null
|
||||||
|
);
|
||||||
|
res.sendStatus(200);
|
||||||
|
} catch (telegramError) {
|
||||||
|
console.error('Ошибка при отправке в Telegram:', telegramError);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Ошибка при отправке сообщения в Telegram',
|
||||||
|
details: telegramError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка в /api/submit:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Внутренняя ошибка сервера',
|
||||||
|
details: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подготавливает данные для расчёта из формы
|
||||||
|
* @param {Object} formData - Данные из формы
|
||||||
|
* @param {Array} fieldSchema - Схема полей калькулятора
|
||||||
|
* @returns {Object} Подготовленные данные для расчёта
|
||||||
|
*/
|
||||||
|
function prepareCalculationData(formData, fieldSchema) {
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
for (const field of fieldSchema) {
|
||||||
|
if (formData[field.name] !== undefined) {
|
||||||
|
const value = formData[field.name];
|
||||||
|
if (field.type === 'number') {
|
||||||
|
data[field.name] = parseFloat(value) || 0;
|
||||||
|
} else {
|
||||||
|
data[field.name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специфичная обработка для мыла (упаковка)
|
||||||
|
if (formData.box !== undefined || formData.filler !== undefined ||
|
||||||
|
formData.ribbon !== undefined || formData.labelValue !== undefined) {
|
||||||
|
data.packaging = {
|
||||||
|
box: parseFloat(formData.box || 0),
|
||||||
|
filler: parseFloat(formData.filler || 0),
|
||||||
|
ribbon: parseFloat(formData.ribbon || 0),
|
||||||
|
label: parseFloat(formData.labelValue || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
85
ecosystem.config.js
Normal file
85
ecosystem.config.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// ecosystem.config.js
|
||||||
|
// PM2 конфигурация для DoSoapCalc
|
||||||
|
//
|
||||||
|
// Использование:
|
||||||
|
// pm2 start ecosystem.config.js # Production режим (node)
|
||||||
|
// pm2 start ecosystem.config.js --env development # Development режим (nodemon)
|
||||||
|
// pm2 restart ecosystem.config.js # Перезапуск всех процессов
|
||||||
|
// pm2 stop ecosystem.config.js # Остановка всех процессов
|
||||||
|
// pm2 delete ecosystem.config.js # Удаление всех процессов
|
||||||
|
// pm2 logs dosoap-backend # Просмотр логов backend
|
||||||
|
// pm2 logs dosoap-frontend # Просмотр логов frontend
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'dosoap-backend',
|
||||||
|
// Production: запуск через node напрямую
|
||||||
|
// Development: запуск через nodemon для авто-перезагрузки
|
||||||
|
script: 'bot.js',
|
||||||
|
cwd: '/home/dosai/projects/DoSoapCalc/backend',
|
||||||
|
interpreter: 'node',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
watch: false,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
// Для запуска через nodemon используйте команду:
|
||||||
|
// pm2 start nodemon --name dosoap-backend -- bot.js --cwd /home/dosai/projects/DoSoapCalc/backend
|
||||||
|
// Или создайте отдельный скрипт start-dev.sh
|
||||||
|
error_file: '/home/dosai/.pm2/logs/dosoap-backend-error.log',
|
||||||
|
out_file: '/home/dosai/.pm2/logs/dosoap-backend-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: '10s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dosoap-backend-dev',
|
||||||
|
// Development версия с nodemon (запускайте отдельно при необходимости)
|
||||||
|
script: 'nodemon',
|
||||||
|
args: 'bot.js',
|
||||||
|
cwd: '/home/dosai/projects/DoSoapCalc/backend',
|
||||||
|
interpreter: 'node',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
watch: false,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
error_file: '/home/dosai/.pm2/logs/dosoap-backend-dev-error.log',
|
||||||
|
out_file: '/home/dosai/.pm2/logs/dosoap-backend-dev-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: '10s',
|
||||||
|
// Не запускается по умолчанию - используйте: pm2 start ecosystem.config.js --only dosoap-backend-dev
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dosoap-frontend',
|
||||||
|
script: 'npx',
|
||||||
|
args: 'serve -s out -l 3000',
|
||||||
|
cwd: '/home/dosai/projects/DoSoapCalc/frontend',
|
||||||
|
interpreter: 'node',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
watch: false,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
error_file: '/home/dosai/.pm2/logs/dosoap-frontend-error.log',
|
||||||
|
out_file: '/home/dosai/.pm2/logs/dosoap-frontend-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: '10s',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
32
frontend/app/[calculator]/page.tsx
Normal file
32
frontend/app/[calculator]/page.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// app/[calculator]/page.tsx
|
||||||
|
// Динамический роут для калькуляторов
|
||||||
|
|
||||||
|
import DynamicCalculator from '@/components/DynamicCalculator';
|
||||||
|
import { getCalculator } from '@/lib/calculators';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
calculator: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CalculatorPage({ params }: PageProps) {
|
||||||
|
const { calculator } = await params;
|
||||||
|
const calculatorModule = getCalculator(calculator);
|
||||||
|
|
||||||
|
if (!calculatorModule) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DynamicCalculator calculatorType={calculator} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация статических путей (для Next.js static export)
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
// Экспортируем все доступные калькуляторы
|
||||||
|
const { getAvailableCalculators } = await import('@/lib/calculators');
|
||||||
|
const calculators = getAvailableCalculators();
|
||||||
|
return calculators.map((calc) => ({ calculator: calc }));
|
||||||
|
}
|
||||||
|
|
||||||
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "DoSoapCalc - Калькулятор себестоимости",
|
||||||
description: "Generated by create next app",
|
description: "Калькулятор себестоимости изделий ручной работы с отправкой результатов в Telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import SoapCalculator from "@/components/SoapCalculator";
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
// Редирект на калькулятор мыла по умолчанию
|
||||||
<SoapCalculator />
|
redirect('/soap');
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
329
frontend/components/DynamicCalculator.tsx
Normal file
329
frontend/components/DynamicCalculator.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
// components/DynamicCalculator.tsx
|
||||||
|
// Универсальный компонент для генерации калькуляторов из схемы
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { getCalculator } from '@/lib/calculators';
|
||||||
|
import { submitCalculator } from '@/lib/api';
|
||||||
|
import FormField from './FormField';
|
||||||
|
import type { Calculator } from '@/types/calculator';
|
||||||
|
|
||||||
|
interface DynamicCalculatorProps {
|
||||||
|
calculatorType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование меток результатов
|
||||||
|
function formatResultLabel(key: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
base: 'Себестоимость основы',
|
||||||
|
aroma: 'Себестоимость отдушки',
|
||||||
|
pigment: 'Себестоимость пигмента',
|
||||||
|
mold: 'Себестоимость формы',
|
||||||
|
packaging: 'Стоимость упаковки',
|
||||||
|
operational: 'Операционные расходы (5%)',
|
||||||
|
wax: 'Себестоимость воска',
|
||||||
|
wick: 'Себестоимость фитиля',
|
||||||
|
};
|
||||||
|
return labels[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicCalculator({ calculatorType }: DynamicCalculatorProps) {
|
||||||
|
const calculator = getCalculator(calculatorType);
|
||||||
|
|
||||||
|
if (!calculator) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
|
||||||
|
<h1 className="text-2xl font-bold text-red-400">
|
||||||
|
Калькулятор "{calculatorType}" не найден
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||||
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Инициализация формы с пустыми значениями
|
||||||
|
useEffect(() => {
|
||||||
|
const initialData: Record<string, string> = {};
|
||||||
|
calculator.fieldSchema.forEach((field) => {
|
||||||
|
initialData[field.name] = '';
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
}, [calculator]);
|
||||||
|
|
||||||
|
// Получаем chat_id из URL
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const id = params.get('chat_id');
|
||||||
|
if (id) {
|
||||||
|
setChatId(id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Преобразование строки в число
|
||||||
|
const toNum = (str: string): number => {
|
||||||
|
const n = parseFloat(str.replace(',', '.'));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновление значения поля
|
||||||
|
const updateField = (fieldName: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подготовка данных для расчёта
|
||||||
|
const prepareCalculationData = (): Record<string, any> => {
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const field of calculator.fieldSchema) {
|
||||||
|
if (field.type === 'number') {
|
||||||
|
data[field.name] = toNum(formData[field.name] || '0');
|
||||||
|
} else {
|
||||||
|
data[field.name] = formData[field.name] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специфичная обработка для мыла (упаковка)
|
||||||
|
if (calculator.id === 'soap') {
|
||||||
|
data.packaging = {
|
||||||
|
box: toNum(formData.box || '0'),
|
||||||
|
filler: toNum(formData.filler || '0'),
|
||||||
|
ribbon: toNum(formData.ribbon || '0'),
|
||||||
|
label: toNum(formData.labelValue || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Выполнение расчёта
|
||||||
|
const calculationData = prepareCalculationData();
|
||||||
|
const result = calculator.calculate(calculationData);
|
||||||
|
|
||||||
|
// Вычисление финальной цены
|
||||||
|
const markup = toNum(formData.markup || '0');
|
||||||
|
const finalPrice = result.total * (1 + markup / 100);
|
||||||
|
const weight = toNum(formData.weight || '0');
|
||||||
|
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
|
||||||
|
|
||||||
|
// Группировка полей по группам
|
||||||
|
const groupedFields = calculator.fieldSchema.reduce((acc, field) => {
|
||||||
|
const group = field.group || 'general';
|
||||||
|
if (!acc[group]) {
|
||||||
|
acc[group] = [];
|
||||||
|
}
|
||||||
|
acc[group].push(field);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof calculator.fieldSchema>);
|
||||||
|
|
||||||
|
// Обработка изменения фото
|
||||||
|
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setPhotoFile(e.target.files[0]);
|
||||||
|
} else {
|
||||||
|
setPhotoFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка отправки формы
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!chatId) {
|
||||||
|
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация обязательных полей
|
||||||
|
const requiredFields = calculator.getRequiredFields();
|
||||||
|
for (const fieldName of requiredFields) {
|
||||||
|
if (!formData[fieldName] || formData[fieldName].trim() === '') {
|
||||||
|
const field = calculator.fieldSchema.find((f) => f.name === fieldName);
|
||||||
|
alert(`❗ Поле "${field?.label || fieldName}" обязательно для заполнения`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготовка данных для отправки
|
||||||
|
const submitData: Record<string, string | number> = {};
|
||||||
|
for (const field of calculator.fieldSchema) {
|
||||||
|
if (field.type === 'number') {
|
||||||
|
const value = toNum(formData[field.name] || '0');
|
||||||
|
submitData[field.name] = value;
|
||||||
|
} else {
|
||||||
|
submitData[field.name] = formData[field.name] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специфичная обработка для мыла (упаковка отправляется как отдельные поля)
|
||||||
|
// Для других калькуляторов поля отправляются как есть
|
||||||
|
if (calculator.id === 'soap') {
|
||||||
|
submitData.box = toNum(formData.box || '0');
|
||||||
|
submitData.filler = toNum(formData.filler || '0');
|
||||||
|
submitData.ribbon = toNum(formData.ribbon || '0');
|
||||||
|
submitData.labelValue = toNum(formData.labelValue || '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем результаты расчёта
|
||||||
|
submitData.totalCost = result.total;
|
||||||
|
submitData.finalPrice = finalPrice;
|
||||||
|
submitData.pricePer100g = pricePer100g;
|
||||||
|
|
||||||
|
// Отправка на backend
|
||||||
|
const response = await submitCalculator(
|
||||||
|
calculatorType,
|
||||||
|
chatId,
|
||||||
|
submitData,
|
||||||
|
photoFile
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
alert('✅ Расчёт успешно отправлен в Telegram!');
|
||||||
|
// Очистка формы
|
||||||
|
const initialData: Record<string, string> = {};
|
||||||
|
calculator.fieldSchema.forEach((field) => {
|
||||||
|
initialData[field.name] = '';
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
setPhotoFile(null);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
alert(`❌ Ошибка: ${response.error || 'Неизвестная ошибка'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="
|
||||||
|
max-w-xl mx-auto p-6 space-y-6
|
||||||
|
bg-gray-800 text-gray-200
|
||||||
|
rounded-lg shadow-lg
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Image
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Logo"
|
||||||
|
width={150}
|
||||||
|
height={50}
|
||||||
|
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Предупреждение о chat_id */}
|
||||||
|
{chatId === null && (
|
||||||
|
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||||||
|
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Заголовок калькулятора */}
|
||||||
|
<h1 className="text-2xl font-bold text-center">
|
||||||
|
Калькулятор: {calculator.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Основные поля (без группы) */}
|
||||||
|
{groupedFields.general && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{groupedFields.general.map((field) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={formData[field.name] || ''}
|
||||||
|
onChange={(value) => updateField(field.name, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Поля с фото */}
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-gray-300">Фото (необязательно)</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
className="
|
||||||
|
bg-gray-700
|
||||||
|
text-gray-200
|
||||||
|
rounded-md
|
||||||
|
border border-gray-600
|
||||||
|
p-2
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{photoFile && (
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(photoFile)}
|
||||||
|
alt="Предпросмотр"
|
||||||
|
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Группированные поля (например, упаковка) */}
|
||||||
|
{Object.entries(groupedFields)
|
||||||
|
.filter(([group]) => group !== 'general')
|
||||||
|
.map(([group, fields]) => (
|
||||||
|
<div key={group} className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-300 capitalize">
|
||||||
|
{group === 'packaging' ? 'Упаковка' : group}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={formData[field.name] || ''}
|
||||||
|
onChange={(value) => updateField(field.name, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Блоки с результатами расчёта */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(result)
|
||||||
|
.filter(([key]) => key !== 'total')
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key} className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||||
|
{formatResultLabel(key)}: {value.toFixed(1)} ₽
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="p-2 bg-gray-600 font-semibold text-gray-200 rounded">
|
||||||
|
Итого себестоимость: {result.total.toFixed(1)} ₽
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||||
|
Итоговая цена с наценкой: {finalPrice.toFixed(1)} ₽
|
||||||
|
</div>
|
||||||
|
{weight > 0 && (
|
||||||
|
<div className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||||
|
Цена за 100 г: {pricePer100g.toFixed(1)} ₽
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка отправки */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="
|
||||||
|
w-full
|
||||||
|
py-2
|
||||||
|
rounded-md
|
||||||
|
bg-sky-500 hover:bg-sky-600
|
||||||
|
text-gray-100 font-semibold
|
||||||
|
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Отправить расчёт в Telegram
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
frontend/components/FormField.tsx
Normal file
103
frontend/components/FormField.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// components/FormField.tsx
|
||||||
|
// Переиспользуемые поля формы
|
||||||
|
|
||||||
|
import { ChangeEvent } from 'react';
|
||||||
|
import type { CalculatorField } from '@/types/calculator';
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
field: CalculatorField;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormField({ field, value, onChange }: FormFieldProps) {
|
||||||
|
const id = `field-${field.id}`;
|
||||||
|
|
||||||
|
if (field.type === 'number') {
|
||||||
|
return (
|
||||||
|
<div className="relative mt-6">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder=" "
|
||||||
|
className={`
|
||||||
|
peer
|
||||||
|
h-10 w-full
|
||||||
|
bg-gray-700
|
||||||
|
border-2 border-gray-600
|
||||||
|
rounded-md
|
||||||
|
text-gray-200
|
||||||
|
placeholder-transparent
|
||||||
|
pl-3
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
appearance-none
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`
|
||||||
|
absolute left-3
|
||||||
|
-top-5
|
||||||
|
text-gray-400
|
||||||
|
transition-all
|
||||||
|
text-xs sm:text-sm md:text-base lg:text-lg
|
||||||
|
peer-placeholder-shown:top-2
|
||||||
|
peer-placeholder-shown:text-gray-500
|
||||||
|
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||||
|
peer-focus:-top-5
|
||||||
|
peer-focus:text-gray-200
|
||||||
|
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{field.label} {field.required && '*'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// text field
|
||||||
|
return (
|
||||||
|
<div className="relative mt-6">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder=" "
|
||||||
|
className={`
|
||||||
|
peer
|
||||||
|
h-10 w-full
|
||||||
|
bg-gray-700
|
||||||
|
border-2 border-gray-600
|
||||||
|
rounded-md
|
||||||
|
text-gray-200
|
||||||
|
placeholder-transparent
|
||||||
|
pl-3
|
||||||
|
focus:outline-none focus:border-sky-500
|
||||||
|
appearance-none
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`
|
||||||
|
absolute left-3
|
||||||
|
-top-5
|
||||||
|
text-gray-400
|
||||||
|
transition-all
|
||||||
|
text-xs sm:text-sm md:text-base lg:text-lg
|
||||||
|
peer-placeholder-shown:top-2
|
||||||
|
peer-placeholder-shown:text-gray-500
|
||||||
|
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||||
|
peer-focus:-top-5
|
||||||
|
peer-focus:text-gray-200
|
||||||
|
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{field.label} {field.required && '*'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -4,6 +4,7 @@
|
|||||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { calculateTotal } from '@/lib/calc';
|
import { calculateTotal } from '@/lib/calc';
|
||||||
|
import { API_BASE_URL } from '@/lib/config';
|
||||||
|
|
||||||
type InputNumberProps = {
|
type InputNumberProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -161,7 +162,7 @@ export default function SoapCalculator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
|
const res = await fetch(`${API_BASE_URL}/api/submit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
61
frontend/lib/api.ts
Normal file
61
frontend/lib/api.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// lib/api.ts
|
||||||
|
// Общая логика отправки данных калькулятора на backend
|
||||||
|
|
||||||
|
import { API_BASE_URL } from './config';
|
||||||
|
|
||||||
|
export interface SubmitResponse {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет данные калькулятора на backend
|
||||||
|
* @param calculatorType - Тип калькулятора (например, 'soap')
|
||||||
|
* @param chatId - ID чата Telegram
|
||||||
|
* @param formData - Данные формы
|
||||||
|
* @param photoFile - Файл фото (опционально)
|
||||||
|
* @returns Promise с результатом отправки
|
||||||
|
*/
|
||||||
|
export async function submitCalculator(
|
||||||
|
calculatorType: string,
|
||||||
|
chatId: string,
|
||||||
|
formData: Record<string, string | number>,
|
||||||
|
photoFile: File | null = null
|
||||||
|
): Promise<SubmitResponse> {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
data.append('chat_id', chatId);
|
||||||
|
|
||||||
|
// Добавляем все поля формы
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
data.append(key, value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем фото, если есть
|
||||||
|
if (photoFile) {
|
||||||
|
data.append('photo', photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/submit/${calculatorType}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json().catch(() => ({ error: await res.text() }));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorData.error || `Ошибка ${res.status}: ${res.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Ошибка сети при отправке расчёта',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
72
frontend/lib/calculators/candle.ts
Normal file
72
frontend/lib/calculators/candle.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
// lib/calculators/candle.ts
|
||||||
|
// Калькулятор свечей для frontend (пример)
|
||||||
|
|
||||||
|
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||||
|
|
||||||
|
const fieldSchema: CalculatorField[] = [
|
||||||
|
{ id: 'candleName', name: 'candleName', label: 'Название свечи', type: 'text', required: true },
|
||||||
|
{ id: 'weight', name: 'weight', label: 'Вес свечи, г', type: 'number', required: true },
|
||||||
|
{ id: 'waxPrice', name: 'waxPrice', label: 'Цена воска за 1 кг, руб', type: 'number', required: false },
|
||||||
|
{ id: 'wickPrice', name: 'wickPrice', label: 'Цена фитиля, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||||
|
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'box', name: 'box', label: 'Упаковка, руб', type: 'number', required: false },
|
||||||
|
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function round(val: number): number {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculate(data: Record<string, any>): CalculationResult {
|
||||||
|
const {
|
||||||
|
weight = 0,
|
||||||
|
waxPrice = 0,
|
||||||
|
wickPrice = 0,
|
||||||
|
aromaPrice = 0,
|
||||||
|
aromaWeight = 1,
|
||||||
|
moldPrice = 0,
|
||||||
|
box = 0,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const wax = (weight / 1000) * waxPrice;
|
||||||
|
const wick = wickPrice;
|
||||||
|
const aroma = ((weight * 0.08) / aromaWeight) * aromaPrice; // 8% отдушки для свечей
|
||||||
|
const mold = moldPrice / 50; // Амортизация формы на 50 свечей
|
||||||
|
const packaging = box;
|
||||||
|
|
||||||
|
const subtotal = wax + wick + aroma + mold + packaging;
|
||||||
|
const operational = subtotal * 0.05;
|
||||||
|
const total = subtotal + operational;
|
||||||
|
|
||||||
|
return {
|
||||||
|
wax: round(wax),
|
||||||
|
wick: round(wick),
|
||||||
|
aroma: round(aroma),
|
||||||
|
mold: round(mold),
|
||||||
|
packaging: round(packaging),
|
||||||
|
operational: round(operational),
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredFields(): string[] {
|
||||||
|
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumericFields(): string[] {
|
||||||
|
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candleCalculator: Calculator = {
|
||||||
|
id: 'candle',
|
||||||
|
name: 'Свеча',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
getRequiredFields,
|
||||||
|
getNumericFields,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default candleCalculator;
|
||||||
|
|
||||||
43
frontend/lib/calculators/index.ts
Normal file
43
frontend/lib/calculators/index.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// lib/calculators/index.ts
|
||||||
|
// Регистрация всех калькуляторов на frontend
|
||||||
|
|
||||||
|
import soapCalculator from './soap';
|
||||||
|
import candleCalculator from './candle';
|
||||||
|
import type { Calculator } from '@/types/calculator';
|
||||||
|
|
||||||
|
// Регистрация калькуляторов
|
||||||
|
const calculators: Record<string, Calculator> = {
|
||||||
|
soap: soapCalculator,
|
||||||
|
candle: candleCalculator,
|
||||||
|
// Здесь будут добавляться новые калькуляторы:
|
||||||
|
// bathBomb: bathBombCalculator,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить калькулятор по типу
|
||||||
|
*/
|
||||||
|
export function getCalculator(type: string): Calculator | null {
|
||||||
|
return calculators[type] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список всех доступных калькуляторов
|
||||||
|
*/
|
||||||
|
export function getAvailableCalculators(): string[] {
|
||||||
|
return Object.keys(calculators);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить, существует ли калькулятор
|
||||||
|
*/
|
||||||
|
export function hasCalculator(type: string): boolean {
|
||||||
|
return type in calculators;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все калькуляторы
|
||||||
|
*/
|
||||||
|
export function getAllCalculators(): Record<string, Calculator> {
|
||||||
|
return calculators;
|
||||||
|
}
|
||||||
|
|
||||||
76
frontend/lib/calculators/soap.ts
Normal file
76
frontend/lib/calculators/soap.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// lib/calculators/soap.ts
|
||||||
|
// Калькулятор мыла для frontend
|
||||||
|
|
||||||
|
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||||
|
|
||||||
|
const fieldSchema: CalculatorField[] = [
|
||||||
|
{ id: 'soapName', name: 'soapName', label: 'Название мыла', type: 'text', required: true },
|
||||||
|
{ id: 'weight', name: 'weight', label: 'Вес мыла, г', type: 'number', required: true },
|
||||||
|
{ id: 'basePrice', name: 'basePrice', label: 'Цена основы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||||
|
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||||
|
{ id: 'pigmentPrice', name: 'pigmentPrice', label: 'Цена пигмента, руб', type: 'number', required: false },
|
||||||
|
{ id: 'pigmentWeight', name: 'pigmentWeight', label: 'Фасовка пигмента, г', type: 'number', required: false },
|
||||||
|
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||||
|
{ id: 'box', name: 'box', label: 'Пакет/коробка, руб', type: 'number', required: false, group: 'packaging' },
|
||||||
|
{ id: 'filler', name: 'filler', label: 'Наполнитель, руб', type: 'number', required: false, group: 'packaging' },
|
||||||
|
{ id: 'ribbon', name: 'ribbon', label: 'Лента, руб', type: 'number', required: false, group: 'packaging' },
|
||||||
|
{ id: 'labelValue', name: 'labelValue', label: 'Наклейка, руб', type: 'number', required: false, group: 'packaging' },
|
||||||
|
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function round(val: number): number {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculate(data: Record<string, any>): CalculationResult {
|
||||||
|
const {
|
||||||
|
weight = 0,
|
||||||
|
basePrice = 0,
|
||||||
|
aromaPrice = 0,
|
||||||
|
aromaWeight = 1,
|
||||||
|
pigmentPrice = 0,
|
||||||
|
pigmentWeight = 1,
|
||||||
|
moldPrice = 0,
|
||||||
|
packaging = { box: 0, filler: 0, ribbon: 0, label: 0 },
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const base = (weight / 1000) * basePrice;
|
||||||
|
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||||
|
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||||
|
const mold = moldPrice / 100;
|
||||||
|
const packagingCost = (packaging.box || 0) + (packaging.filler || 0) + (packaging.ribbon || 0) + (packaging.label || 0);
|
||||||
|
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||||
|
const operational = subtotal * 0.05;
|
||||||
|
const total = subtotal + operational;
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: round(base),
|
||||||
|
aroma: round(aroma),
|
||||||
|
pigment: round(pigment),
|
||||||
|
mold: round(mold),
|
||||||
|
packaging: round(packagingCost),
|
||||||
|
operational: round(operational),
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredFields(): string[] {
|
||||||
|
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumericFields(): string[] {
|
||||||
|
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const soapCalculator: Calculator = {
|
||||||
|
id: 'soap',
|
||||||
|
name: 'Мыло',
|
||||||
|
fieldSchema,
|
||||||
|
calculate,
|
||||||
|
getRequiredFields,
|
||||||
|
getNumericFields,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default soapCalculator;
|
||||||
|
|
||||||
9
frontend/lib/config.ts
Normal file
9
frontend/lib/config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// lib/config.ts
|
||||||
|
// Конфигурация для frontend
|
||||||
|
|
||||||
|
// Для Next.js в production можно использовать переменные окружения
|
||||||
|
// В development и статическом экспорте используем значения по умолчанию
|
||||||
|
export const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||||
|
'https://api-dosoap.duckdns.org';
|
||||||
|
|
||||||
32
frontend/types/calculator.ts
Normal file
32
frontend/types/calculator.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// types/calculator.ts
|
||||||
|
// Типы для системы калькуляторов
|
||||||
|
|
||||||
|
export type FieldType = 'text' | 'number';
|
||||||
|
|
||||||
|
export interface CalculatorField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: FieldType;
|
||||||
|
required: boolean;
|
||||||
|
group?: string; // Группировка полей (например, "packaging", "ingredients")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculationResult {
|
||||||
|
[key: string]: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Calculator {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fieldSchema: CalculatorField[];
|
||||||
|
calculate: (data: Record<string, any>) => CalculationResult;
|
||||||
|
getRequiredFields: () => string[];
|
||||||
|
getNumericFields: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculatorFormData {
|
||||||
|
[key: string]: string | number | File | null;
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user