Compare commits
No commits in common. "25178247e7881f1fb9a1df7e2c938006463389a3" and "800aaafb24f4d4ddfc292a78628deb31c48f0125" have entirely different histories.
25178247e7
...
800aaafb24
@ -1,193 +0,0 @@
|
|||||||
# Руководство по добавлению нового калькулятора
|
|
||||||
|
|
||||||
Это руководство описывает, как добавить новый калькулятор в систему 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
76
CHANGELOG.md
@ -1,76 +0,0 @@
|
|||||||
# 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
242
PROJECT_INFO.md
@ -1,242 +0,0 @@
|
|||||||
# 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
147
README.md
@ -1,147 +0,0 @@
|
|||||||
# 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,30 +14,25 @@ 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 config = require('./config/env');
|
const streamifier = require('streamifier');
|
||||||
|
|
||||||
// Импортируем маршруты API
|
|
||||||
const apiRoutes = require('./routes/api');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const HTTP_PORT = config.HTTP_PORT;
|
const HTTP_PORT = 3001; // порт для BACKEND API
|
||||||
const BOT_TOKEN = config.BOT_TOKEN;
|
const BOT_TOKEN = '7801636590:AAFphqOK0Dqta7v9VCLkTPGYC1OujNIFgXA'; // ← замените на свой токен
|
||||||
const WEBAPP_BASE_URL = config.WEBAPP_BASE_URL;
|
const WEBAPP_BASE_URL = 'https://dosoap.duckdns.org'; // ← например: https://xyz123.ngrok.io
|
||||||
|
|
||||||
// 1) Запускаем бота (polling)
|
// 1) Запускаем бота (polling)
|
||||||
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||||
|
|
||||||
// Сохраняем бота в app.locals для использования в роутах
|
// 2) Настраиваем multer (храним файлы в памяти)
|
||||||
app.locals.bot = bot;
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
// 3) Разрешаем CORS для фронтенда
|
// 3) Разрешаем CORS для фронтенда
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
res.header('Access-Control-Allow-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();
|
||||||
});
|
});
|
||||||
@ -45,8 +40,108 @@ app.use((req, res, next) => {
|
|||||||
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
// 5) Подключаем API маршруты
|
// 5) Обработчик POST /api/submit (multipart/form-data)
|
||||||
app.use('/api', apiRoutes);
|
app.post(
|
||||||
|
'/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([
|
||||||
@ -56,8 +151,7 @@ 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,
|
||||||
@ -67,7 +161,7 @@ bot.onText(/\/menu/, (msg) => {
|
|||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Открыть калькулятор мыла',
|
text: 'Открыть калькулятор',
|
||||||
web_app: { url },
|
web_app: { url },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
// 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())
|
|
||||||
: ['*'],
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// 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,7 +10,6 @@
|
|||||||
"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",
|
||||||
@ -601,17 +600,6 @@
|
|||||||
"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,7 +12,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
// 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;
|
|
||||||
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
// 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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
// 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: "DoSoapCalc - Калькулятор себестоимости",
|
title: "Create Next App",
|
||||||
description: "Калькулятор себестоимости изделий ручной работы с отправкой результатов в Telegram",
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import SoapCalculator from "@/components/SoapCalculator";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// Редирект на калькулятор мыла по умолчанию
|
return (
|
||||||
redirect('/soap');
|
<SoapCalculator />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,329 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
// 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,7 +4,6 @@
|
|||||||
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;
|
||||||
@ -162,7 +161,7 @@ export default function SoapCalculator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/submit`, {
|
const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
// 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 : 'Ошибка сети при отправке расчёта',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
// 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;
|
|
||||||
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
// 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;
|
|
||||||
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
// 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';
|
|
||||||
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
// 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