Compare commits
64 Commits
1a4653298d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb56342f4 | ||
|
|
fd54177ada | ||
|
|
fd2e7cfb07 | ||
|
|
d7982931cd | ||
|
|
ea0edafba5 | ||
|
|
1fdb244fd4 | ||
|
|
ab54530500 | ||
|
|
ffe30033da | ||
|
|
35ca11552d | ||
|
|
b28bb4b636 | ||
|
|
37472d612a | ||
|
|
e0097a164d | ||
|
|
6a783deba1 | ||
|
|
920eb53660 | ||
|
|
1f88e156b7 | ||
|
|
003210dcfc | ||
|
|
346d9a77d2 | ||
|
|
38457394c1 | ||
|
|
e7915df634 | ||
|
|
b2433f38d8 | ||
|
|
8e116c76a4 | ||
|
|
bf3fb5fef0 | ||
|
|
1fc64c035e | ||
|
|
b93bb9e8ad | ||
|
|
35adcb3043 | ||
|
|
3d9669dd8e | ||
|
|
6a9f8b5465 | ||
|
|
a86120dd53 | ||
|
|
e114231541 | ||
|
|
b7197e0da5 | ||
|
|
ee1c4af5c3 | ||
|
|
834520a045 | ||
|
|
da82100b60 | ||
|
|
81acd49fd9 | ||
|
|
0f8631bf20 | ||
|
|
3801bc4949 | ||
|
|
985ee23810 | ||
|
|
840acca51a | ||
|
|
6c770f0a87 | ||
|
|
0868d37484 | ||
|
|
55c1402d99 | ||
|
|
18fcdecae8 | ||
|
|
f058ca91ad | ||
|
|
796316d969 | ||
|
|
f3b5771c09 | ||
|
|
2ce0c585ff | ||
|
|
6cc07b0ba6 | ||
|
|
b5478c143f | ||
|
|
be1ac2ed49 | ||
|
|
2b1dca9e92 | ||
|
|
99ef902a31 | ||
|
|
8626c9aff4 | ||
|
|
444e5d2b91 | ||
|
|
8f4cff55e9 | ||
|
|
52fe013375 | ||
|
|
a20a4d0e09 | ||
|
|
486f3619ff | ||
|
|
d3ba054027 | ||
|
|
30a0df9c64 | ||
|
|
d2f37faa7b | ||
|
|
de011efba9 | ||
|
|
cd90b0d58a | ||
|
|
75912e5cfb | ||
|
|
43e760aad6 |
@@ -1,10 +1,81 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "ctx7sk-541e7992-c38f-442f-8902-ae99645f2477"
|
||||
}
|
||||
}
|
||||
"context7": {
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "ctx7sk-541e7992-c38f-442f-8902-ae99645f2477"
|
||||
}
|
||||
},
|
||||
"shadcn": {
|
||||
"command": "/usr/bin/docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--init",
|
||||
"-v", "/var/www/fastuser/data/www/crm.clientright.ru:/workspace",
|
||||
"-w", "/workspace",
|
||||
"node:20-alpine",
|
||||
"npx",
|
||||
"-y",
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"antd-components": {
|
||||
"command": "/usr/bin/docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--init",
|
||||
"-v", "/var/www/fastuser/data/www/crm.clientright.ru:/workspace",
|
||||
"-w", "/workspace",
|
||||
"node:20-alpine",
|
||||
"npx",
|
||||
"-y",
|
||||
"@jzone-mcp/antd-components-mcp"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
|
||||
|
||||
|
||||
"n8n-mcp": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--init",
|
||||
"-e", "MCP_MODE=stdio",
|
||||
"-e", "LOG_LEVEL=error",
|
||||
"-e", "DISABLE_CONSOLE_OUTPUT=true",
|
||||
"-e", "N8N_API_URL=https://n8n.clientright.pro/",
|
||||
"-e", "N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzMwYWVjZC1hYjExLTQxODEtOWIyYy1iMDZhZWEzMTNmNzQiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzUzNjg3NDM4fQ.XJjyYXXOoO4eUGAfkSVRMJzLYvi25hczsp2F7j4UV7Y",
|
||||
"ghcr.io/czlonkowski/n8n-mcp:latest"
|
||||
]
|
||||
},
|
||||
"memory": {
|
||||
"url": "http://185.197.75.249:9000/sse"
|
||||
}
|
||||
|
||||
|
||||
,
|
||||
"n8n-mcp2": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"supergateway",
|
||||
"--streamableHttp",
|
||||
"https://n8n.clientright.pro/mcp-server/http",
|
||||
"--header",
|
||||
"authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzMwYWVjZC1hYjExLTQxODEtOWIyYy1iMDZhZWEzMTNmNzQiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6Ijc5MTQzNDU5LTM0NzMtNDQ2Mi05MzU1LTZmMTAzZTdlMzAxNCIsImlhdCI6MTc2NDU3OTI0N30.g-WQpgWMjdFWVpUMaRP023MCQqfk_e3ollLyPpcE_Io"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
96
AI_DRAWER_DEBUG.md
Normal file
96
AI_DRAWER_DEBUG.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 🔍 Диагностика проблемы AI Drawer
|
||||
|
||||
## Проблема
|
||||
Ошибка: "Ошибка при получении ответа. Попробуйте еще раз."
|
||||
|
||||
## Что проверить
|
||||
|
||||
### 1. Проверить формат сообщения от n8n
|
||||
|
||||
n8n может публиковать сообщение в двух форматах:
|
||||
|
||||
**Формат 1 (просто текст):**
|
||||
```
|
||||
"Текст ответа от AI"
|
||||
```
|
||||
|
||||
**Формат 2 (JSON объект):**
|
||||
```json
|
||||
{
|
||||
"task_id": "task-123",
|
||||
"response": "Текст ответа",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Проверить Redis ключ
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
|
||||
GET "ai:response:cache:task-691209e225894-1762789858"
|
||||
```
|
||||
|
||||
Если ключ есть → ответ сохранен, но SSE не получил
|
||||
Если ключа нет → n8n не сохраняет в ключ (нужно настроить)
|
||||
|
||||
### 3. Проверить логи SSE
|
||||
|
||||
В консоли браузера должны быть логи:
|
||||
- `AI Drawer: SSE connection opened`
|
||||
- `AI Drawer: Received response via SSE`
|
||||
|
||||
Если их нет → SSE не подключается
|
||||
|
||||
### 4. Проверить публикацию в канал
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
|
||||
MONITOR
|
||||
```
|
||||
|
||||
Затем отправьте сообщение в AI Drawer - должны видеть PUBLISH команду
|
||||
|
||||
## Решение
|
||||
|
||||
### Если n8n публикует только в канал (без ключа):
|
||||
|
||||
1. **Добавьте Redis SET ноду в n8n** перед PUBLISH:
|
||||
- Operation: `Set`
|
||||
- Key: `ai:response:cache:{{ $json.taskId }}`
|
||||
- Value: JSON с ответом
|
||||
- TTL: 300 секунд
|
||||
|
||||
2. **Или** используйте текущий код - SSE endpoint сам сохранит в ключ когда получит из канала
|
||||
|
||||
### Если SSE не подключается:
|
||||
|
||||
1. Проверьте что `/aiassist/ai_sse.php` доступен
|
||||
2. Проверьте логи PHP на ошибки
|
||||
3. Проверьте консоль браузера на ошибки CORS/сети
|
||||
|
||||
## Текущая архитектура
|
||||
|
||||
```
|
||||
n8n → Redis PUBLISH (канал) → SSE endpoint получает → сохраняет в ключ → отправляет браузеру
|
||||
↓
|
||||
Если SSE не получил → fallback проверяет ключ
|
||||
```
|
||||
|
||||
## Что исправлено
|
||||
|
||||
✅ SSE endpoint теперь:
|
||||
- Принимает и JSON и простой текст
|
||||
- Сохраняет ответ в Redis ключ при получении
|
||||
- Проверяет ключ при подключении (на случай если ответ уже есть)
|
||||
|
||||
✅ JavaScript теперь:
|
||||
- Не вызывает fallback если уже получил ответ
|
||||
- Проверяет Redis ключ периодически если SSE не работает
|
||||
- Логирует все действия для отладки
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Проверьте что n8n сохраняет в ключ `ai:response:cache:{taskId}` ПЕРЕД публикацией
|
||||
2. Проверьте логи в консоли браузера
|
||||
3. Проверьте логи PHP (error_log)
|
||||
|
||||
105
AI_DRAWER_SSE_SESSION_LOG.md
Normal file
105
AI_DRAWER_SSE_SESSION_LOG.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Лог сессии: Реализация SSE + Redis Pub/Sub для AI Drawer
|
||||
|
||||
**Дата:** 11 ноября 2025
|
||||
**Задача:** Переход с polling на real-time коммуникацию через SSE и Redis Pub/Sub
|
||||
|
||||
## Проблема
|
||||
|
||||
Изначально использовался механизм polling:
|
||||
- n8n отправлял ответ в `callback_ai_response.php`
|
||||
- Ответ сохранялся в БД таблицу `ai_responses`
|
||||
- Frontend периодически опрашивал `get_ai_result.php`
|
||||
- Неэффективно и не real-time
|
||||
|
||||
## Решение
|
||||
|
||||
Реализована архитектура на основе:
|
||||
- **Server-Sent Events (SSE)** для real-time доставки ответов
|
||||
- **Redis Pub/Sub** для передачи сообщений от n8n
|
||||
- **Redis кэш** для fallback механизма
|
||||
|
||||
## Реализованные компоненты
|
||||
|
||||
### 1. SSE Endpoint (`aiassist/ai_sse.php`)
|
||||
- Подписывается на Redis канал `ai:response:{taskId}`
|
||||
- Отправляет события через SSE
|
||||
- Поддерживает plain text и JSON форматы от n8n
|
||||
- Кэширует ответы в Redis для надежности
|
||||
- Логирование в `/logs/ai_sse_debug.log`
|
||||
|
||||
### 2. Обновлен `n8n_proxy.php`
|
||||
- Убран callback URL
|
||||
- Добавлена передача Redis параметров в n8n:
|
||||
- `redisChannel`
|
||||
- `redisHost`
|
||||
- `redisPort`
|
||||
- `redisPassword`
|
||||
|
||||
### 3. Обновлен `ai-drawer-simple.js`
|
||||
- Заменен `startPolling()` на `startSSEListener()`
|
||||
- Использует `EventSource` API для SSE
|
||||
- Fallback через `checkRedisDirectly()` если SSE не работает
|
||||
- Обработка событий: `connected`, `response`, `error`, `heartbeat`
|
||||
|
||||
### 4. Fallback endpoint (`aiassist/check_redis_response.php`)
|
||||
- Прямое чтение из Redis кэша
|
||||
- Используется если SSE не подключился или потерял соединение
|
||||
|
||||
## Настройка n8n
|
||||
|
||||
### Redis Node Configuration:
|
||||
- **Operation:** Publish
|
||||
- **Channel:** `{{ $json.body.redisChannel }}` или `ai:response:{{ $json.body.taskId }}`
|
||||
- **Data:** `{{ $json.output }}` (можно plain text или JSON)
|
||||
|
||||
### Рекомендуется:
|
||||
1. Сначала выполнить `Redis SET` в ключ `ai:response:cache:{taskId}` (TTL 300 сек)
|
||||
2. Затем выполнить `Redis PUBLISH` в канал `ai:response:{taskId}`
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Успешные тесты:
|
||||
- ✅ Получение plain text ответов от n8n
|
||||
- ✅ Кэширование в Redis
|
||||
- ✅ SSE доставка ответов
|
||||
- ✅ Fallback механизм работает
|
||||
|
||||
### Логи:
|
||||
- Файл: `/logs/ai_sse_debug.log`
|
||||
- Последние успешные запросы:
|
||||
- `task-691326da57708-1762862810` (15:07:07)
|
||||
- `task-6913281c9ff7e-1762863132` (15:12:36)
|
||||
|
||||
## Преимущества новой архитектуры
|
||||
|
||||
1. **Real-time:** Ответы приходят мгновенно через SSE
|
||||
2. **Надежность:** Fallback через Redis кэш
|
||||
3. **Гибкость:** Поддержка plain text и JSON форматов
|
||||
4. **Производительность:** Нет постоянного polling
|
||||
5. **Масштабируемость:** Redis Pub/Sub поддерживает множество подписчиков
|
||||
|
||||
## Файлы в репозитории
|
||||
|
||||
### Новые файлы:
|
||||
- `aiassist/ai_sse.php`
|
||||
- `aiassist/check_redis_response.php`
|
||||
- `AI_DRAWER_REDIS_SSE.md`
|
||||
- `N8N_REDIS_SETUP.md`
|
||||
- `N8N_REDIS_FIX.md`
|
||||
|
||||
### Измененные файлы:
|
||||
- `aiassist/n8n_proxy.php`
|
||||
- `layouts/v7/resources/js/ai-drawer-simple.js`
|
||||
|
||||
## Git коммит
|
||||
|
||||
```
|
||||
1a465329 Реализован SSE + Redis Pub/Sub для AI Drawer
|
||||
```
|
||||
|
||||
## Статус
|
||||
|
||||
✅ **Работает в продакшене**
|
||||
✅ **Все изменения запушены в Git**
|
||||
✅ **Документация обновлена**
|
||||
|
||||
@@ -87,3 +87,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/api_attach_documents.
|
||||
|
||||
## 🎯 Готово к использованию в n8n!
|
||||
|
||||
|
||||
|
||||
@@ -234,3 +234,5 @@ Contact: 396625
|
||||
|
||||
**Готово к использованию!** 🎉
|
||||
|
||||
|
||||
|
||||
|
||||
219
LOGS/AI_DOCUMENT_GENERATION_SESSION.md
Normal file
219
LOGS/AI_DOCUMENT_GENERATION_SESSION.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 📝 Лог сессии: Реализация генерации документов для AI Ассистента
|
||||
|
||||
**Дата:** 2025-01-12
|
||||
**Участники:** Фёдор, AI Assistant
|
||||
**Тема:** Создание инструмента для генерации документов из шаблонов и с текстом от AI
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Цель сессии
|
||||
|
||||
Реализовать функционал генерации документов (претензий, исков, жалоб, ходатайств) для AI Ассистента с возможностью использования шаблонов Nextcloud и форматирования Markdown.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Обсужденные вопросы
|
||||
|
||||
### 1. Шаблонизация документов в Nextcloud
|
||||
|
||||
**Вопрос:** Как настроить шаблоны в Nextcloud? Есть ли понятный механизм шаблонизирования?
|
||||
|
||||
**Анализ:**
|
||||
- Проверен API Nextcloud Direct Editing - endpoint `/templates` не существует
|
||||
- Найдена папка `/Templates/` в корне пользователя admin
|
||||
- ONLYOFFICE хранит "Общие шаблоны" отдельно от обычной папки Templates
|
||||
- Шаблоны доступны через WebDAV PROPFIND
|
||||
|
||||
**Решение:**
|
||||
- Использовать WebDAV для получения списка шаблонов
|
||||
- Создать API для работы с шаблонами через WebDAV
|
||||
- Поддержать заполнение переменных через PHPWord
|
||||
|
||||
### 2. Процесс создания документов AI Ассистентом
|
||||
|
||||
**Вопрос:** Как AI Ассистент будет создавать документы?
|
||||
|
||||
**Решение:**
|
||||
1. Пользователь просит создать документ
|
||||
2. AI Drawer отправляет запрос в n8n
|
||||
3. n8n → GPT-4 анализирует запрос и генерирует текст
|
||||
4. n8n вызывает API создания документа
|
||||
5. API создает DOCX с текстом (поддержка Markdown форматирования)
|
||||
6. Документ сохраняется в S3 в папку проекта
|
||||
7. Возвращается ссылка на редактирование в OnlyOffice
|
||||
|
||||
### 3. Форматирование документов
|
||||
|
||||
**Вопрос:** Можно ли сделать красивое форматирование документов?
|
||||
|
||||
**Решение:** ✅ Да! Реализована поддержка Markdown:
|
||||
- Заголовки: `# H1`, `## H2`, `### H3`
|
||||
- Жирный: `**текст**` или `__текст__`
|
||||
- Курсив: `*текст*` или `_текст_`
|
||||
- Код: `` `текст` ``
|
||||
- Маркированные списки: `- пункт` или `* пункт`
|
||||
- Нумерованные списки: `1. пункт`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Реализованные компоненты
|
||||
|
||||
### 1. API создания документов с текстом
|
||||
|
||||
**Файл:** `/crm_extensions/file_storage/api/create_document_with_text.php`
|
||||
|
||||
**Функционал:**
|
||||
- Создает DOCX/XLSX/PPTX с текстом от AI
|
||||
- Поддержка Markdown форматирования
|
||||
- Сохранение в S3 в правильную папку проекта
|
||||
- Возврат ссылки на редактирование в OnlyOffice
|
||||
|
||||
**Особенности:**
|
||||
- Поддержка JSON POST запросов
|
||||
- Fallback на простой DOCX если PHPWord недоступен
|
||||
- Правильная обработка пробелов (замена на подчеркивания)
|
||||
- Правильный путь: `crm2/CRM_Active_Files/Documents/...` (без `/crm/` в начале)
|
||||
|
||||
### 2. API создания документов из шаблонов
|
||||
|
||||
**Файл:** `/crm_extensions/file_storage/api/create_from_template.php`
|
||||
|
||||
**Функционал:**
|
||||
- Скачивает шаблон из Nextcloud через WebDAV
|
||||
- Заполняет переменные через PHPWord
|
||||
- Сохраняет готовый документ в папку проекта
|
||||
|
||||
### 3. API получения списка шаблонов
|
||||
|
||||
**Файл:** `/crm_extensions/file_storage/api/list_templates.php`
|
||||
|
||||
**Функционал:**
|
||||
- Получает список шаблонов из Nextcloud через WebDAV PROPFIND
|
||||
- Фильтрует только Office файлы
|
||||
- Возвращает JSON с метаданными
|
||||
|
||||
### 4. Установка PHPWord
|
||||
|
||||
**Команда:**
|
||||
```bash
|
||||
composer require phpoffice/phpword
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- ✅ PHPWord 1.4.0 установлен
|
||||
- ✅ Поддержка форматирования Markdown
|
||||
- ✅ Красивое оформление документов
|
||||
|
||||
---
|
||||
|
||||
## 📝 Исправленные проблемы
|
||||
|
||||
### Проблема 1: PHPWord не установлен
|
||||
- **Решение:** Установлен через composer
|
||||
- **Дополнительно:** Добавлен fallback на простой DOCX через ZIP
|
||||
|
||||
### Проблема 2: JSON POST не обрабатывался
|
||||
- **Решение:** Добавлена проверка Content-Type и парсинг JSON из php://input
|
||||
|
||||
### Проблема 3: Неправильный путь к файлам
|
||||
- **Было:** `/crm/crm2/CRM_Active_Files/...`
|
||||
- **Стало:** `crm2/CRM_Active_Files/...`
|
||||
- **Решение:** Исправлен путь в `create_document_with_text.php`
|
||||
|
||||
### Проблема 4: Пробелы в именах папок
|
||||
- **Было:** `Крылова ГБУ ЖИЛИЩНИК...`
|
||||
- **Стало:** `Крылова_ГБУ_ЖИЛИЩНИК...`
|
||||
- **Решение:** Добавлена замена пробелов на подчеркивания в `recordName`
|
||||
|
||||
---
|
||||
|
||||
## 📚 Созданная документация
|
||||
|
||||
1. **AI_DOCUMENT_TOOL_INSTRUCTION.md** - Инструкция для AI Ассистента
|
||||
2. **AI_DOCUMENT_GENERATION_FLOW.md** - Описание процесса создания документов
|
||||
3. **MARKDOWN_FORMATTING.md** - Справочник по Markdown форматированию
|
||||
4. **NEXTCLOUD_TEMPLATES.md** - Работа с шаблонами Nextcloud
|
||||
5. **NEXTCLOUD_TEMPLATES_API_ANALYSIS.md** - Анализ API шаблонов
|
||||
6. **ONLYOFFICE_TEMPLATES_ANALYSIS.md** - Анализ шаблонов ONLYOFFICE
|
||||
7. **N8N_HTTP_REQUEST_CURL.md** - cURL команды для n8n
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Результаты
|
||||
|
||||
### ✅ Реализовано:
|
||||
|
||||
1. **API создания документов** - работает, протестирован
|
||||
2. **Поддержка Markdown** - заголовки, жирный, курсив, списки, код
|
||||
3. **Правильные пути** - документы сохраняются в правильную структуру
|
||||
4. **Обработка пробелов** - автоматическая замена на подчеркивания
|
||||
5. **PHPWord установлен** - красивое форматирование документов
|
||||
6. **Документация** - полная инструкция для AI и разработчиков
|
||||
|
||||
### 📊 Статистика:
|
||||
|
||||
- **Создано файлов:** 7+ (API, документация)
|
||||
- **Установлено библиотек:** PHPWord 1.4.0
|
||||
- **Исправлено проблем:** 4
|
||||
- **Поддерживаемых форматов:** DOCX, XLSX, PPTX
|
||||
- **Поддерживаемых элементов Markdown:** 6 типов
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Следующие шаги
|
||||
|
||||
1. **Настроить в n8n:**
|
||||
- Добавить HTTP Request ноду для создания документов
|
||||
- Подключить к AI workflow
|
||||
- Протестировать создание документов
|
||||
|
||||
2. **Улучшения (опционально):**
|
||||
- Добавить поддержку шаблонов с переменными
|
||||
- Расширенное форматирование (таблицы, изображения)
|
||||
- Автоматическое определение типа документа
|
||||
|
||||
3. **Интеграция с AI:**
|
||||
- Добавить инструмент в список доступных для AI
|
||||
- Протестировать генерацию документов через AI Drawer
|
||||
|
||||
---
|
||||
|
||||
## 📁 Измененные файлы
|
||||
|
||||
### Новые файлы:
|
||||
- `/crm_extensions/file_storage/api/create_document_with_text.php`
|
||||
- `/crm_extensions/file_storage/api/create_from_template.php`
|
||||
- `/crm_extensions/file_storage/api/list_templates.php`
|
||||
- `/crm_extensions/file_storage/docs/AI_DOCUMENT_TOOL_INSTRUCTION.md`
|
||||
- `/crm_extensions/file_storage/docs/AI_DOCUMENT_GENERATION_FLOW.md`
|
||||
- `/crm_extensions/file_storage/docs/MARKDOWN_FORMATTING.md`
|
||||
- `/crm_extensions/file_storage/docs/NEXTCLOUD_TEMPLATES.md`
|
||||
- `/crm_extensions/file_storage/docs/NEXTCLOUD_TEMPLATES_API_ANALYSIS.md`
|
||||
- `/crm_extensions/file_storage/docs/ONLYOFFICE_TEMPLATES_ANALYSIS.md`
|
||||
- `/crm_extensions/file_storage/docs/N8N_HTTP_REQUEST_CURL.md`
|
||||
|
||||
### Обновленные файлы:
|
||||
- `composer.json` - добавлен phpoffice/phpword
|
||||
- `composer.lock` - обновлен после установки PHPWord
|
||||
|
||||
---
|
||||
|
||||
## 💡 Ключевые решения
|
||||
|
||||
1. **Использование Markdown** - стандартный синтаксис, понятный AI
|
||||
2. **WebDAV вместо API** - надежнее, работает всегда
|
||||
3. **Fallback механизм** - работает даже без PHPWord
|
||||
4. **Правильная структура путей** - соответствует существующей системе
|
||||
|
||||
---
|
||||
|
||||
## ✅ Статус: Готово к использованию
|
||||
|
||||
Все компоненты реализованы, протестированы и готовы к использованию в n8n workflow.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
89
NEXTCLOUD_BUTTON_FIX_REDIS.md
Normal file
89
NEXTCLOUD_BUTTON_FIX_REDIS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# 🐛 FIX: Кнопка "Через Nextcloud" → Ошибка подключения к API
|
||||
|
||||
**Дата:** 2 ноября 2025
|
||||
**Проблема:** Кнопка "📚 Через Nextcloud" показывала ошибку "Ошибка подключения к API"
|
||||
|
||||
## 🔍 Диагностика
|
||||
|
||||
### Симптомы:
|
||||
1. ✅ `nextcloud_open.php` **работал в CLI** (возвращал правильный JSON)
|
||||
2. ❌ Через веб (curl/браузер) возвращал **HTTP 500 (пустой ответ)**
|
||||
3. ❌ JavaScript fetch() получал пустой ответ → показывал ошибку
|
||||
|
||||
### Причина:
|
||||
**Redis PHP extension** был установлен только для PHP 7.2, а Apache использовал **PHP 7.3**!
|
||||
|
||||
```bash
|
||||
# PHP CLI (работало):
|
||||
php -v # PHP 7.2 (имеет redis extension)
|
||||
|
||||
# Apache (не работало):
|
||||
phpinfo() # PHP 7.3 (НЕТ redis extension!)
|
||||
```
|
||||
|
||||
## ✅ Решение
|
||||
|
||||
### 1. Обнаружили модуль .so:
|
||||
```bash
|
||||
find /opt/php73 -name "redis.so"
|
||||
# /opt/php73/lib/php/extensions/no-debug-non-zts-20180731/redis.so
|
||||
```
|
||||
|
||||
### 2. Создали конфиг:
|
||||
```bash
|
||||
echo "extension=redis.so" > /opt/php73/mods-available/redis.ini
|
||||
ln -s /opt/php73/mods-available/redis.ini /opt/php73/conf.d/redis.ini
|
||||
```
|
||||
|
||||
### 3. Перезапустили Apache:
|
||||
```bash
|
||||
systemctl restart apache2
|
||||
```
|
||||
|
||||
### 4. Проверка:
|
||||
```bash
|
||||
curl https://crm.clientright.ru/crm_extensions/file_storage/api/nextcloud_open.php?recordId=395695
|
||||
|
||||
# Ответ:
|
||||
{
|
||||
"success": true,
|
||||
"fileId": 115163,
|
||||
"redirectUrl": "https://office.clientright.ru:8443/apps/files/files/115163?...",
|
||||
"source": "redis"
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Результат
|
||||
|
||||
✅ Кнопка "📚 Через Nextcloud" **РАБОТАЕТ**
|
||||
✅ FileID получается из **Redis** (быстро!)
|
||||
✅ Файлы открываются в **OnlyOffice через Nextcloud**
|
||||
|
||||
## 📂 Изменённые файлы
|
||||
|
||||
- `/opt/php73/mods-available/redis.ini` (создан)
|
||||
- `/opt/php73/conf.d/redis.ini` (symlink создан)
|
||||
- `nextcloud_open.php` (оптимизирован, убрана PROPFIND fallback логика)
|
||||
|
||||
## 🧪 Проверка других кнопок
|
||||
|
||||
| Кнопка | Статус | Примечание |
|
||||
|--------|--------|------------|
|
||||
| ⚡ **Быстро** | ✅ Работает | S3 → OnlyOffice Standalone |
|
||||
| 📚 **Через Nextcloud** | ✅ Работает | Redis → Nextcloud → OnlyOffice |
|
||||
| 📁 **Папка в Nextcloud** | ✅ Работает | Открывает папку проекта |
|
||||
| 📄 **Скачать** | ✅ Работает | Прямая ссылка S3 |
|
||||
|
||||
## 🔧 Для проверки в будущем:
|
||||
|
||||
```bash
|
||||
# Проверка модулей PHP 7.3:
|
||||
curl -s "https://crm.clientright.ru/crm_extensions/file_storage/api/test_modules.php"
|
||||
# Должно показать: {"mysqli":true,"redis":true,"json":true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant (Claude Sonnet 4.5)
|
||||
**Время исправления:** ~2 часа (большая часть на диагностику)
|
||||
**Сложность:** ⭐⭐⭐ (3/5) - нетривиальная проблема с разными версиями PHP
|
||||
138
NEXTCLOUD_FOLDER_BUTTONS_FIX.md
Normal file
138
NEXTCLOUD_FOLDER_BUTTONS_FIX.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 🔧 ИСПРАВЛЕНИЕ: Кнопки "Папка в Nextcloud" не работали
|
||||
|
||||
## ❌ ПРОБЛЕМА:
|
||||
В модулях CRM кнопка **"Папка в Nextcloud"** была неактивна и не реагировала на клики.
|
||||
|
||||
## 🔍 ПРИЧИНА:
|
||||
В JavaScript файле `crm_extensions/nextcloud_editor/js/nextcloud-editor.js` **отсутствовали функции**, которые вызывались из шаблонов:
|
||||
|
||||
**Было в JS:**
|
||||
- ✅ `openProjectFolder()` — ТОЛЬКО ДЛЯ Project
|
||||
|
||||
**Вызывалось из шаблонов, но НЕ СУЩЕСТВОВАЛО:**
|
||||
- ❌ `openRecordFolder()` — для HelpDesk, Invoice, SalesOrder, PurchaseOrder, Leads, Quotes, Potentials
|
||||
- ❌ `openAccountFolder()` — для Accounts
|
||||
- ❌ `openContactFolder()` — для Contacts
|
||||
|
||||
## ✅ РЕШЕНИЕ:
|
||||
|
||||
### 1. Добавлена универсальная функция `openRecordFolder()`
|
||||
```javascript
|
||||
function openRecordFolder(moduleName, recordId, recordName) {
|
||||
// Нормализация имени (убираем кавычки, заменяем пробелы)
|
||||
if (recordName) {
|
||||
recordName = recordName.replace(/"/g, '_').replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
// Формируем путь к папке
|
||||
const folderName = recordName ? `${recordName}_${recordId}` : `${moduleName.toLowerCase()}_${recordId}`;
|
||||
const encodedFolderName = encodeURIComponent(folderName);
|
||||
const nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
|
||||
const folderUrl = `${nextcloudUrl}/apps/files/?dir=/crm/crm2/CRM_Active_Files/Documents/${moduleName}/${encodedFolderName}`;
|
||||
|
||||
// Открываем в новом окне
|
||||
window.open(folderUrl, 'nextcloud_folder', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||
}
|
||||
```
|
||||
|
||||
**Используется в модулях:**
|
||||
- HelpDesk (тикеты)
|
||||
- Invoice (счета)
|
||||
- SalesOrder (заказы)
|
||||
- PurchaseOrder (закупки)
|
||||
- Leads (лиды)
|
||||
- Quotes (предложения)
|
||||
- Potentials (сделки)
|
||||
|
||||
### 2. Добавлена специализированная функция `openAccountFolder()`
|
||||
```javascript
|
||||
function openAccountFolder(accountId, accountName) {
|
||||
// Нормализация имени контрагента
|
||||
if (accountName) {
|
||||
accountName = accountName.replace(/"/g, '_').replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
const folderName = accountName ? `${accountName}_${accountId}` : `account_${accountId}`;
|
||||
const encodedFolderName = encodeURIComponent(folderName);
|
||||
|
||||
const folderUrl = `https://office.clientright.ru:8443/apps/files/?dir=/crm/crm2/CRM_Active_Files/Documents/Accounts/${encodedFolderName}`;
|
||||
|
||||
window.open(folderUrl, 'nextcloud_folder', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||
}
|
||||
```
|
||||
|
||||
**Используется в модуле:**
|
||||
- Accounts (контрагенты)
|
||||
|
||||
### 3. Добавлена специализированная функция `openContactFolder()`
|
||||
```javascript
|
||||
function openContactFolder(contactId, firstName, lastName) {
|
||||
// Составление имени из firstName и lastName
|
||||
let contactName = '';
|
||||
if (firstName || lastName) {
|
||||
contactName = `${firstName || ''}_${lastName || ''}`.replace(/^_+|_+$/g, '');
|
||||
contactName = contactName.replace(/"/g, '_').replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
const folderName = contactName ? `${contactName}_${contactId}` : `contact_${contactId}`;
|
||||
const encodedFolderName = encodeURIComponent(folderName);
|
||||
|
||||
const folderUrl = `https://office.clientright.ru:8443/apps/files/?dir=/crm/crm2/CRM_Active_Files/Documents/Contacts/${encodedFolderName}`;
|
||||
|
||||
window.open(folderUrl, 'nextcloud_folder', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||
}
|
||||
```
|
||||
|
||||
**Используется в модуле:**
|
||||
- Contacts (контакты)
|
||||
|
||||
### 4. Очищен кеш Smarty
|
||||
```bash
|
||||
rm -rf test/templates_c/v7/*.php
|
||||
```
|
||||
|
||||
## 📋 ПРОВЕРКА:
|
||||
|
||||
### Функции в JS:
|
||||
```bash
|
||||
grep -n "^function open.*Folder" crm_extensions/nextcloud_editor/js/nextcloud-editor.js
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
```
|
||||
9:function openRecordFolder(moduleName, recordId, recordName) {
|
||||
37:function openProjectFolder(projectId, projectName) {
|
||||
65:function openAccountFolder(accountId, accountName) {
|
||||
87:function openContactFolder(contactId, firstName, lastName) {
|
||||
```
|
||||
|
||||
✅ **ВСЕ 4 ФУНКЦИИ НА МЕСТЕ!**
|
||||
|
||||
### Где используются:
|
||||
|
||||
| Модуль | Функция | Параметры |
|
||||
|--------|---------|-----------|
|
||||
| HelpDesk | `openRecordFolder()` | 'HelpDesk', recordId, ticket_no |
|
||||
| Invoice | `openRecordFolder()` | 'Invoice', recordId, invoice_no |
|
||||
| SalesOrder | `openRecordFolder()` | 'SalesOrder', recordId, salesorder_no |
|
||||
| PurchaseOrder | `openRecordFolder()` | 'PurchaseOrder', recordId, purchaseorder_no |
|
||||
| Leads | `openRecordFolder()` | 'Leads', recordId, firstname_lastname |
|
||||
| Quotes | `openRecordFolder()` | 'Quotes', recordId, quote_no |
|
||||
| Potentials | `openRecordFolder()` | 'Potentials', recordId, potentialname |
|
||||
| Accounts | `openAccountFolder()` | accountId, accountname |
|
||||
| Contacts | `openContactFolder()` | contactId, firstname, lastname |
|
||||
| Project | `openProjectFolder()` | projectId, projectname |
|
||||
|
||||
## 🎯 РЕЗУЛЬТАТ:
|
||||
|
||||
✅ **КНОПКИ "Папка в Nextcloud" ТЕПЕРЬ РАБОТАЮТ ВО ВСЕХ МОДУЛЯХ!**
|
||||
|
||||
При клике на кнопку открывается **новое окно** с Nextcloud, где отображается папка соответствующей записи CRM.
|
||||
|
||||
## 📁 ИЗМЕНЕННЫЕ ФАЙЛЫ:
|
||||
- `crm_extensions/nextcloud_editor/js/nextcloud-editor.js` — добавлены 3 недостающие функции
|
||||
- `test/templates_c/v7/*.php` — очищен кеш (автоматически пересоздастся)
|
||||
|
||||
## 📅 ДАТА ИСПРАВЛЕНИЯ:
|
||||
02.11.2025
|
||||
111
PROJECT_390983_FIXED.md
Normal file
111
PROJECT_390983_FIXED.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# ✅ ПРОЕКТ 390983 - ВСЁ ИСПРАВЛЕНО!
|
||||
|
||||
## 📊 ИТОГОВАЯ СТАТИСТИКА:
|
||||
|
||||
**БЫЛО:**
|
||||
- ❌ Все файлы недоступны (HTTP 403/404)
|
||||
- ❌ Кнопки не работают
|
||||
- ❌ Пути битые
|
||||
|
||||
**СТАЛО:**
|
||||
- ✅ 8 файлов полностью работают
|
||||
- ✅ Все кнопки работают
|
||||
- ✅ Пути исправлены
|
||||
|
||||
## 🔧 ИСПРАВЛЕННЫЕ ПРОБЛЕМЫ:
|
||||
|
||||
### 1. Кнопка "📁 Папка в Nextcloud" не работала
|
||||
**Проблема:** Отсутствовали JS функции
|
||||
**Решение:** Добавлены:
|
||||
- `openRecordFolder(moduleName, recordId, recordName)`
|
||||
- `openAccountFolder(accountId, accountName)`
|
||||
- `openContactFolder(contactId, firstName, lastName)`
|
||||
- `openProjectFolder(projectId, projectName)`
|
||||
|
||||
### 2. Файлы в неправильной папке
|
||||
**Проблема:** Файлы были в `Documents/Макарова...` вместо `Documents/Project/Макарова...`
|
||||
**Решение:** Скопированы в правильную папку
|
||||
|
||||
### 3. Файлы были приватные
|
||||
**Проблема:** ACL не позволял публичный доступ
|
||||
**Решение:** Установлен `public-read` для всех файлов
|
||||
|
||||
### 4. HTML Entities в путях
|
||||
**Проблема:** В БД были `М...` вместо нормальных букв
|
||||
**Решение:** Обновлены пути с правильным UTF-8
|
||||
|
||||
### 5. Пробелы не закодированы
|
||||
**Проблема:** URL содержали пробелы вместо `%20`
|
||||
**Решение:** Сохранены правильно URL-encoded пути
|
||||
|
||||
### 6. Кнопка "📚 Через Nextcloud" не работала
|
||||
**Проблема:** Двойная кодировка при передаче URL из Smarty в JavaScript
|
||||
**Решение:** Теперь `nextcloud_open.php` получает filename из БД по recordId
|
||||
|
||||
## 🎯 ФАЙЛЫ ПРОЕКТА 390983:
|
||||
|
||||
| ID | Файл | Статус |
|
||||
|----|------|--------|
|
||||
| 390986 | Договор | ✅ HTTP 200 |
|
||||
| 390988 | Подтверждение оплаты | ✅ HTTP 200 |
|
||||
| 390990 | Претензия | ✅ HTTP 200 |
|
||||
| 390992 | Ответ на претензию | ✅ HTTP 200 |
|
||||
| 390994 | Прочие документы | ✅ HTTP 200 |
|
||||
| 390996 | 7 заявление потребителя | ✅ HTTP 200 |
|
||||
| 391199 | 11 Доказательство соблюдения | ✅ HTTP 200 |
|
||||
| 395695 | Исковое заявление (проект) | ✅ HTTP 200 |
|
||||
| 396839 | Счёт и акт Аэрофлот | ❌ Отсутствует |
|
||||
| 396840 | analytical_report | ❌ Отсутствует |
|
||||
|
||||
**ИТОГО: 8/10 (80%) восстановлено**
|
||||
|
||||
## 🚀 ЧТО ТЕПЕРЬ РАБОТАЕТ:
|
||||
|
||||
### ⚡ Кнопка "Быстро" (editInNextcloud)
|
||||
- ✅ Открывает файлы прямо из S3 в OnlyOffice
|
||||
- ✅ Без Nextcloud — быстрее!
|
||||
- ✅ Работает идеально
|
||||
- **РЕКОМЕНДУЕТСЯ ИСПОЛЬЗОВАТЬ**
|
||||
|
||||
### 📚 Кнопка "Через Nextcloud" (openViaNextcloud)
|
||||
- ✅ ИСПРАВЛЕНА! Теперь работает
|
||||
- Получает filename из БД (нет проблем с кодировкой)
|
||||
- Открывает файл в Nextcloud Files UI
|
||||
- Доступно версионирование
|
||||
|
||||
### 📄 Кнопка "Скачать"
|
||||
- ✅ Работает для всех 8 файлов
|
||||
- Прямая ссылка на S3
|
||||
|
||||
### 📁 Кнопка "Папка в Nextcloud"
|
||||
- ✅ ИСПРАВЛЕНА во всех модулях!
|
||||
- Открывает папку записи в Nextcloud
|
||||
|
||||
## 📝 ТЕХНИЧЕСКИЕ ДЕТАЛИ:
|
||||
|
||||
### Файлы в S3:
|
||||
```
|
||||
s3://f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/
|
||||
crm2/CRM_Active_Files/Documents/Project/
|
||||
Макарова_ИП_Большакова_Инна_Борисовна_390983/
|
||||
├─ Договор_390986.pdf
|
||||
├─ Подтверждение_оплаты_390988.pdf
|
||||
├─ Претензия_390990.pdf
|
||||
├─ Ответ_на_претензию_390992.pdf
|
||||
├─ Прочие_документы_390994.pdf
|
||||
├─ 7 заявление потребителя_390996.pdf
|
||||
├─ 11 Доказательство соблюдения претензионного порядк_391199.pdf
|
||||
└─ Исковое заявление (проект)_395695.docx
|
||||
```
|
||||
|
||||
### В БД (vtiger_notes):
|
||||
- `filename` = полный URL-encoded S3 URL
|
||||
- `s3_key` = путь без домена
|
||||
- `s3_bucket` = bucket ID
|
||||
- `filelocationtype` = 'E' (External)
|
||||
|
||||
## 📅 ДАТА ИСПРАВЛЕНИЯ:
|
||||
02.11.2025
|
||||
|
||||
## 🎉 РЕЗУЛЬТАТ:
|
||||
**ВСЁ РАБОТАЕТ! МОЖНО ИСПОЛЬЗОВАТЬ!**
|
||||
146
RESTORE_INSTRUCTIONS.md
Normal file
146
RESTORE_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Инструкция по восстановлению удаленных файлов и защите от повторных удалений
|
||||
|
||||
## 📋 Что было сделано:
|
||||
|
||||
1. ✅ Создан скрипт для настройки Nextcloud (`fix_nextcloud_settings.php`)
|
||||
2. ✅ Создан скрипт для восстановления файлов (`restore_all_deleted_files.php`)
|
||||
3. ✅ Создан скрипт для регулярной индексации (`nextcloud_scan_files.sh`)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Порядок выполнения:
|
||||
|
||||
### Шаг 1: Настройка Nextcloud (защита от удалений)
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru
|
||||
php fix_nextcloud_settings.php
|
||||
```
|
||||
|
||||
**Что делает:**
|
||||
- Отключает `DeleteOrphanedItems` (главная причина удалений)
|
||||
- Включает `readonly` для External Storage
|
||||
- Увеличивает retention корзины до 365 дней
|
||||
- Создает скрипт для регулярной индексации
|
||||
|
||||
---
|
||||
|
||||
### Шаг 2: Восстановление файлов (сначала проверка)
|
||||
|
||||
**Сначала проверка (dry-run):**
|
||||
```bash
|
||||
php restore_all_deleted_files.php --dry-run
|
||||
```
|
||||
|
||||
Это покажет, сколько файлов будет восстановлено без реального восстановления.
|
||||
|
||||
**Ограничение количества (для теста):**
|
||||
```bash
|
||||
php restore_all_deleted_files.php --dry-run 100
|
||||
```
|
||||
|
||||
**Восстановление всех файлов:**
|
||||
```bash
|
||||
php restore_all_deleted_files.php
|
||||
```
|
||||
|
||||
**Восстановление с ограничением (для безопасности):**
|
||||
```bash
|
||||
php restore_all_deleted_files.php "" 1000
|
||||
```
|
||||
|
||||
**Восстановление только файлов проекта:**
|
||||
```bash
|
||||
php restore_all_deleted_files.php "" "" "crm2/CRM_Active_Files/Documents/Project/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 3: Настройка регулярной индексации
|
||||
|
||||
**Добавить в crontab:**
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
**Добавить строку:**
|
||||
```
|
||||
0 */6 * * * /var/www/fastuser/data/www/crm.clientright.ru/nextcloud_scan_files.sh
|
||||
```
|
||||
|
||||
Это будет сканировать файлы каждые 6 часов.
|
||||
|
||||
**Или сканировать только внешнее хранилище (быстрее):**
|
||||
Отредактируйте `nextcloud_scan_files.sh` и раскомментируйте строку:
|
||||
```bash
|
||||
docker exec -u www-data nextcloud-fresh php occ files:scan --path="/crm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Статистика удалений:
|
||||
|
||||
- **Всего delete markers:** ~25,200
|
||||
- **Пик удалений:** 1 ноября 2025, 09:00 утра (7,080 файлов)
|
||||
- **Причина:** DeleteOrphanedItems в Nextcloud
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ВАЖНО:
|
||||
|
||||
1. **Сначала настройте Nextcloud** (Шаг 1), чтобы предотвратить новые удаления
|
||||
2. **Проверьте dry-run** перед массовым восстановлением
|
||||
3. **Восстанавливайте постепенно** (по 1000-5000 файлов за раз)
|
||||
4. **Проверяйте логи** после восстановления
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Проверка статуса:
|
||||
|
||||
**Проверить статус задач Nextcloud:**
|
||||
```bash
|
||||
docker exec -u www-data nextcloud-fresh php occ background-job:list
|
||||
```
|
||||
|
||||
**Проверить настройки External Storage:**
|
||||
```bash
|
||||
docker exec -u www-data nextcloud-fresh php occ files_external:list
|
||||
```
|
||||
|
||||
**Проверить retention корзины:**
|
||||
```bash
|
||||
docker exec -u www-data nextcloud-fresh php occ config:app:get files trashbin_retention_obligation
|
||||
```
|
||||
|
||||
**Проверить логи восстановления:**
|
||||
```bash
|
||||
ls -lh /var/www/fastuser/data/www/crm.clientright.ru/restore_log_*.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Защита от повторных удалений:
|
||||
|
||||
После выполнения всех шагов система будет защищена:
|
||||
|
||||
1. ✅ DeleteOrphanedItems отключен
|
||||
2. ✅ External Storage в режиме readonly
|
||||
3. ✅ Retention корзины увеличен до 365 дней
|
||||
4. ✅ Регулярная индексация файлов настроена
|
||||
|
||||
---
|
||||
|
||||
## 📝 Логи:
|
||||
|
||||
- Логи восстановления: `restore_log_YYYY-MM-DD_HH-MM-SS.json`
|
||||
- Логи индексации: `/var/log/nextcloud_scan.log`
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Если что-то пошло не так:
|
||||
|
||||
1. Проверьте логи восстановления
|
||||
2. Проверьте доступность Docker контейнера Nextcloud
|
||||
3. Проверьте права доступа к S3
|
||||
4. Проверьте логи Nextcloud: `docker logs nextcloud-fresh`
|
||||
|
||||
108
SESSION_LOG_ARCHIVE_FIX.md
Normal file
108
SESSION_LOG_ARCHIVE_FIX.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Лог сессии: Исправление архивации проектов с S3 файлами
|
||||
|
||||
## Дата: 2025-11-21
|
||||
|
||||
## Проблема
|
||||
Архивация проектов не работала после миграции на S3:
|
||||
- Возвращался ответ `{"success":true,"result":"Nothing to archive"}`
|
||||
- Затем появились ошибки `"_ is missing!"` для всех документов
|
||||
- После исправления появились ошибки `"S3 file download failed"` для всех файлов
|
||||
|
||||
## Причины проблем
|
||||
|
||||
### 1. Неправильная обработка S3 файлов
|
||||
- Метод `getPaths()` пытался обработать S3 файлы как локальные
|
||||
- `Vtiger_Record_Model` не всегда содержит поля `s3_bucket`, `s3_key`, `filelocationtype`
|
||||
- Нужно было явно запрашивать эти данные из БД
|
||||
|
||||
### 2. Отсутствие поддержки связанных документов
|
||||
- Архив включал только документы самого проекта
|
||||
- Не включались документы из связанных сущностей (контакты, контрагенты)
|
||||
- Аналогично функционалу отправки исковых писем через pochta-sud.ru
|
||||
|
||||
### 3. Ошибки при скачивании из S3
|
||||
- Неправильный путь к `vendor/autoload.php` (относительный путь не работал)
|
||||
- Слишком длинное имя временного файла (`File name too long`)
|
||||
- Использовался `basename($fileName)` где `$fileName` содержал URL-encoded полный путь
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. Добавлен метод `getRelatedDocs($projectId)`
|
||||
- Получает документы из связанных сущностей проекта:
|
||||
- Контакт (`linktoaccountscontacts`)
|
||||
- Контрагенты (`cf_1994`, `cf_2274`, `cf_2276`)
|
||||
- Возвращает массив документов с полями: `notesid`, `title`, `filename`, `filelocationtype`, `s3_bucket`, `s3_key`
|
||||
|
||||
### 2. Добавлен метод `downloadS3File($s3Bucket, $s3Key, $fileName)`
|
||||
- Скачивает файлы из S3 во временную папку
|
||||
- Использует AWS SDK для работы с S3
|
||||
- Сохраняет пути временных файлов для последующей очистки
|
||||
- Обрабатывает ошибки с подробным логированием
|
||||
|
||||
### 3. Добавлен метод `cleanupTempFiles()`
|
||||
- Очищает все временные файлы после создания архива
|
||||
- Вызывается в `finally` блоке для гарантированной очистки
|
||||
|
||||
### 4. Исправлен метод `getPaths($docs)`
|
||||
- Поддержка как `Vtiger_Record_Model` объектов, так и массивов из `getRelatedDocs`
|
||||
- **ВСЕГДА** запрашивает `s3_bucket`, `s3_key`, `filelocationtype` из БД для Record Models
|
||||
- Правильно определяет S3 файлы (`filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key)`)
|
||||
- Для S3 файлов вызывает `downloadS3File()`
|
||||
- Для локальных файлов использует `getFileDetails()`
|
||||
|
||||
### 5. Исправлен метод `getArchive($id)`
|
||||
- Для проектов собирает документы из основной записи и связанных сущностей
|
||||
- Предотвращает дубликаты документов
|
||||
- Вызывает `getPaths()` с объединенным списком документов
|
||||
- Добавлено подробное логирование для отладки
|
||||
- Обработка ошибок с возвратом детальной информации
|
||||
|
||||
### 6. Исправления в `downloadS3File()`
|
||||
- Поиск `vendor/autoload.php` по нескольким путям (относительный и абсолютный)
|
||||
- Использование короткого имени временного файла (только расширение, без полного пути)
|
||||
- Подробное логирование в `/tmp/s3_download_debug.log`
|
||||
|
||||
## Измененные файлы
|
||||
|
||||
### `modules/Vtiger/services/Base.php`
|
||||
- Добавлен метод `getRelatedDocs($projectId)` - получение документов из связанных сущностей
|
||||
- Добавлен метод `downloadS3File($s3Bucket, $s3Key, $fileName)` - скачивание из S3
|
||||
- Добавлен метод `cleanupTempFiles()` - очистка временных файлов
|
||||
- Добавлено свойство `private static $tempFiles = []` - хранение путей временных файлов
|
||||
- Исправлен метод `getPaths($docs)` - поддержка S3 и связанных документов
|
||||
- Исправлен метод `getArchive($id)` - сбор документов из связанных сущностей для проектов
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тестовый скрипт `test_s3_download.php`
|
||||
- Создан для прямого тестирования `downloadS3File()`
|
||||
- Успешно скачал файл из S3 (9.5 МБ)
|
||||
- Подтвердил работоспособность исправлений
|
||||
|
||||
### Результат
|
||||
- ✅ Архив успешно создается с 25 документами для проекта 396447
|
||||
- ✅ Включаются документы из проекта и связанных сущностей
|
||||
- ✅ S3 файлы корректно скачиваются и добавляются в архив
|
||||
- ✅ Временные файлы автоматически очищаются
|
||||
|
||||
## Технические детали
|
||||
|
||||
### S3 конфигурация
|
||||
- Используется конфиг из `crm_extensions/file_storage/config.php`
|
||||
- Endpoint: `https://s3.twcstorage.ru`
|
||||
- Bucket и Key берутся из полей `vtiger_notes.s3_bucket` и `vtiger_notes.s3_key`
|
||||
|
||||
### Временные файлы
|
||||
- Сохраняются в `sys_get_temp_dir()` (обычно `/tmp`)
|
||||
- Имена: `s3_{uniqid}.{extension}`
|
||||
- Автоматически удаляются после создания архива
|
||||
|
||||
### Логирование
|
||||
- Основные логи: `error_log()` (системный лог PHP)
|
||||
- Отладочные логи: `/tmp/s3_download_debug.log` (временный, удален после исправления)
|
||||
- Ошибки: `/tmp/s3_download_errors.log` (временный, удален после исправления)
|
||||
|
||||
## Коммит
|
||||
Изменения закоммичены в git с описанием исправлений.
|
||||
|
||||
|
||||
236
analyze_deletion_patterns.php
Normal file
236
analyze_deletion_patterns.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
|
||||
echo "Детальный анализ паттернов удалений\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
// Анализируем удаления по времени
|
||||
echo "1. Анализ удалений по времени суток...\n";
|
||||
$deletionsByHour = [];
|
||||
$deletionsByDay = [];
|
||||
$batchDeletions = []; // Массовые удаления (много файлов за короткое время)
|
||||
|
||||
$totalChecked = 0;
|
||||
$maxToCheck = 10000;
|
||||
|
||||
try {
|
||||
$isTruncated = true;
|
||||
$continuationToken = null;
|
||||
$pageCount = 0;
|
||||
$maxPages = 20;
|
||||
|
||||
$currentBatch = [];
|
||||
$lastDeleteTime = null;
|
||||
|
||||
while ($isTruncated && $pageCount < $maxPages && $totalChecked < $maxToCheck) {
|
||||
$params = [
|
||||
'Bucket' => $s3Bucket,
|
||||
'Prefix' => 'crm2/CRM_Active_Files/Documents/Project/',
|
||||
'MaxKeys' => 1000
|
||||
];
|
||||
|
||||
if ($continuationToken) {
|
||||
$params['ContinuationToken'] = $continuationToken;
|
||||
}
|
||||
|
||||
$versions = $s3Client->listObjectVersions($params);
|
||||
$pageCount++;
|
||||
|
||||
if (isset($versions['DeleteMarkers'])) {
|
||||
foreach ($versions['DeleteMarkers'] as $marker) {
|
||||
$totalChecked++;
|
||||
$deleteDate = isset($marker['LastModified']) ? $marker['LastModified'] : null;
|
||||
|
||||
if ($deleteDate) {
|
||||
$dateTime = new DateTime($deleteDate);
|
||||
$hour = $dateTime->format('H');
|
||||
$day = $dateTime->format('Y-m-d');
|
||||
|
||||
if (!isset($deletionsByHour[$hour])) {
|
||||
$deletionsByHour[$hour] = 0;
|
||||
}
|
||||
$deletionsByHour[$hour]++;
|
||||
|
||||
if (!isset($deletionsByDay[$day])) {
|
||||
$deletionsByDay[$day] = 0;
|
||||
}
|
||||
$deletionsByDay[$day]++;
|
||||
|
||||
// Определяем массовые удаления (более 10 файлов за минуту)
|
||||
$deleteTimestamp = strtotime($deleteDate);
|
||||
if ($lastDeleteTime && abs($deleteTimestamp - $lastDeleteTime) < 60) {
|
||||
$currentBatch[] = $marker;
|
||||
} else {
|
||||
if (count($currentBatch) > 10) {
|
||||
$batchDeletions[] = [
|
||||
'count' => count($currentBatch),
|
||||
'time' => date('Y-m-d H:i:s', $lastDeleteTime),
|
||||
'files' => array_slice($currentBatch, 0, 5) // Первые 5 для примера
|
||||
];
|
||||
}
|
||||
$currentBatch = [$marker];
|
||||
}
|
||||
$lastDeleteTime = $deleteTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isTruncated = isset($versions['IsTruncated']) && $versions['IsTruncated'];
|
||||
$continuationToken = isset($versions['NextContinuationToken']) ? $versions['NextContinuationToken'] : null;
|
||||
|
||||
if (!$isTruncated) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем последний батч
|
||||
if (count($currentBatch) > 10) {
|
||||
$batchDeletions[] = [
|
||||
'count' => count($currentBatch),
|
||||
'time' => $lastDeleteTime ? date('Y-m-d H:i:s', $lastDeleteTime) : 'неизвестно',
|
||||
'files' => array_slice($currentBatch, 0, 5)
|
||||
];
|
||||
}
|
||||
|
||||
echo " Проверено delete markers: $totalChecked\n\n";
|
||||
|
||||
echo " Удаления по часам суток:\n";
|
||||
ksort($deletionsByHour);
|
||||
foreach ($deletionsByHour as $hour => $count) {
|
||||
if ($count > 0) {
|
||||
echo " {$hour}:00 - " . ($hour + 1) . ":00: $count удалений\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
echo " Удаления по дням (топ 15):\n";
|
||||
arsort($deletionsByDay);
|
||||
$count = 0;
|
||||
foreach ($deletionsByDay as $day => $deleteCount) {
|
||||
echo " $day: $deleteCount удалений\n";
|
||||
if (++$count >= 15) break;
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (!empty($batchDeletions)) {
|
||||
echo " Массовые удаления (более 10 файлов за минуту): " . count($batchDeletions) . "\n";
|
||||
foreach (array_slice($batchDeletions, 0, 5) as $batch) {
|
||||
echo " Время: {$batch['time']}, удалено файлов: {$batch['count']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
echo " Ошибка: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Проверяем, может быть это связано с удалением документов в CRM
|
||||
echo "2. Проверка связи с удалением документов в CRM...\n";
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
// Проверяем, сколько документов было удалено в последние дни
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT DATE(e.modifiedtime) as delete_date, COUNT(*) as count
|
||||
FROM vtiger_crmentity e
|
||||
INNER JOIN vtiger_notes n ON n.notesid = e.crmid
|
||||
WHERE e.deleted = 1
|
||||
AND n.filelocationtype = "E"
|
||||
AND e.modifiedtime >= DATE_SUB(NOW(), INTERVAL 60 DAY)
|
||||
GROUP BY DATE(e.modifiedtime)
|
||||
ORDER BY delete_date DESC
|
||||
LIMIT 20
|
||||
');
|
||||
$stmt->execute();
|
||||
$deletedDocs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($deletedDocs)) {
|
||||
echo " Удаленные документы в CRM (за последние 60 дней):\n";
|
||||
foreach ($deletedDocs as $doc) {
|
||||
echo " {$doc['delete_date']}: {$doc['count']} документов\n";
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
echo " Удаленных документов не найдено\n\n";
|
||||
}
|
||||
|
||||
// Сравниваем даты удалений в S3 и CRM
|
||||
echo "3. Сравнение дат удалений в S3 и CRM...\n";
|
||||
if (!empty($deletionsByDay) && !empty($deletedDocs)) {
|
||||
echo " Сравнение:\n";
|
||||
foreach ($deletedDocs as $doc) {
|
||||
$crmDate = $doc['delete_date'];
|
||||
$s3Count = $deletionsByDay[$crmDate] ?? 0;
|
||||
$crmCount = $doc['count'];
|
||||
|
||||
if ($s3Count > 0) {
|
||||
echo " $crmDate:\n";
|
||||
echo " Удалено в CRM: $crmCount документов\n";
|
||||
echo " Delete markers в S3: $s3Count\n";
|
||||
|
||||
if ($s3Count > $crmCount * 2) {
|
||||
echo " ⚠️ В S3 удалено значительно больше файлов!\n";
|
||||
} elseif (abs($s3Count - $crmCount) <= 10) {
|
||||
echo " ✅ Количество примерно совпадает\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ВЫВОДЫ:\n\n";
|
||||
|
||||
// Определяем наиболее вероятную причину
|
||||
$maxHour = array_search(max($deletionsByHour), $deletionsByHour);
|
||||
$maxDay = array_search(max($deletionsByDay), $deletionsByDay);
|
||||
$maxDayCount = max($deletionsByDay);
|
||||
|
||||
echo "1. Пик удалений:\n";
|
||||
echo " - Время: {$maxHour}:00\n";
|
||||
echo " - День: $maxDay ($maxDayCount удалений)\n\n";
|
||||
|
||||
if ($maxHour >= 6 && $maxHour <= 8) {
|
||||
echo "2. 💡 ВЕРОЯТНАЯ ПРИЧИНА: Автоматическая задача (cron job)\n";
|
||||
echo " Удаления происходят рано утром (6-8 утра) - типичное время для cron\n\n";
|
||||
}
|
||||
|
||||
if (!empty($batchDeletions)) {
|
||||
echo "3. 💡 ВЕРОЯТНАЯ ПРИЧИНА: Массовое удаление (скрипт или автоматизация)\n";
|
||||
echo " Найдено " . count($batchDeletions) . " случаев массового удаления\n\n";
|
||||
}
|
||||
|
||||
echo "4. 💡 РЕКОМЕНДАЦИЯ: Проверить:\n";
|
||||
echo " - DeleteOrphanedItems в Nextcloud (запускается ежедневно)\n";
|
||||
echo " - Cron задачи, которые могут удалять файлы\n";
|
||||
echo " - Логи Nextcloud на предмет массовых удалений\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "ОШИБКА: " . $e->getMessage() . "\n";
|
||||
echo "Trace: " . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
203
analyze_deletions.php
Normal file
203
analyze_deletions.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
|
||||
echo "Анализ удалений файлов из S3\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
// Анализируем паттерны удалений
|
||||
echo "1. Анализ паттернов удалений по датам...\n";
|
||||
|
||||
$deletionsByDate = [];
|
||||
$deletionsByProject = [];
|
||||
$totalChecked = 0;
|
||||
$maxToCheck = 5000; // Ограничиваем для скорости
|
||||
|
||||
try {
|
||||
$isTruncated = true;
|
||||
$continuationToken = null;
|
||||
$pageCount = 0;
|
||||
$maxPages = 10;
|
||||
|
||||
while ($isTruncated && $pageCount < $maxPages && $totalChecked < $maxToCheck) {
|
||||
$params = [
|
||||
'Bucket' => $s3Bucket,
|
||||
'Prefix' => 'crm2/CRM_Active_Files/Documents/Project/',
|
||||
'MaxKeys' => 1000
|
||||
];
|
||||
|
||||
if ($continuationToken) {
|
||||
$params['ContinuationToken'] = $continuationToken;
|
||||
}
|
||||
|
||||
$versions = $s3Client->listObjectVersions($params);
|
||||
$pageCount++;
|
||||
|
||||
if (isset($versions['DeleteMarkers'])) {
|
||||
foreach ($versions['DeleteMarkers'] as $marker) {
|
||||
$totalChecked++;
|
||||
$key = $marker['Key'];
|
||||
$deleteDate = isset($marker['LastModified']) ? $marker['LastModified'] : null;
|
||||
|
||||
if ($deleteDate) {
|
||||
$dateKey = substr($deleteDate, 0, 10); // YYYY-MM-DD
|
||||
if (!isset($deletionsByDate[$dateKey])) {
|
||||
$deletionsByDate[$dateKey] = 0;
|
||||
}
|
||||
$deletionsByDate[$dateKey]++;
|
||||
}
|
||||
|
||||
// Извлекаем ID проекта из пути
|
||||
if (preg_match('/Project\/([^\/]+)_(\d+)\//', $key, $matches)) {
|
||||
$projectId = $matches[2];
|
||||
if (!isset($deletionsByProject[$projectId])) {
|
||||
$deletionsByProject[$projectId] = 0;
|
||||
}
|
||||
$deletionsByProject[$projectId]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isTruncated = isset($versions['IsTruncated']) && $versions['IsTruncated'];
|
||||
$continuationToken = isset($versions['NextContinuationToken']) ? $versions['NextContinuationToken'] : null;
|
||||
|
||||
if (!$isTruncated) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
echo " Проверено delete markers: $totalChecked\n\n";
|
||||
|
||||
// Сортируем по датам
|
||||
krsort($deletionsByDate);
|
||||
|
||||
echo " Удаления по датам (топ 20):\n";
|
||||
$count = 0;
|
||||
foreach ($deletionsByDate as $date => $count) {
|
||||
if ($count > 0) {
|
||||
echo " $date: $count удалений\n";
|
||||
if (++$count >= 20) break;
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// Сортируем проекты по количеству удалений
|
||||
arsort($deletionsByProject);
|
||||
|
||||
echo " Проекты с наибольшим количеством удалений (топ 10):\n";
|
||||
$count = 0;
|
||||
foreach ($deletionsByProject as $projectId => $deleteCount) {
|
||||
echo " Проект $projectId: $deleteCount удалений\n";
|
||||
if (++$count >= 10) break;
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
echo " Ошибка: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Проверяем логи системы
|
||||
echo "2. Проверка логов на наличие записей об удалениях...\n";
|
||||
$logFiles = [
|
||||
'/var/log/nginx/error.log',
|
||||
'/var/log/apache2/error.log',
|
||||
'/var/www/fastuser/data/www/crm.clientright.ru/logs/debug.log',
|
||||
'/var/www/fastuser/data/www/crm.clientright.ru/logs/s3_debug.log',
|
||||
];
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
if (file_exists($logFile)) {
|
||||
echo " Проверка: $logFile\n";
|
||||
$lines = file($logFile);
|
||||
if ($lines) {
|
||||
$deleteLines = array_filter($lines, function($line) {
|
||||
return stripos($line, 'delete') !== false &&
|
||||
(stripos($line, 's3') !== false || stripos($line, 'file') !== false);
|
||||
});
|
||||
|
||||
if (!empty($deleteLines)) {
|
||||
echo " Найдено строк с упоминанием удалений: " . count($deleteLines) . "\n";
|
||||
echo " Последние 5 записей:\n";
|
||||
foreach (array_slice($deleteLines, -5) as $line) {
|
||||
echo " " . substr(trim($line), 0, 150) . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " Записей об удалениях не найдено\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, есть ли скрипты крона, которые могут удалять файлы
|
||||
echo "3. Поиск скриптов, которые могут удалять файлы...\n";
|
||||
$cronFiles = [
|
||||
'/var/spool/cron/crontabs/root',
|
||||
'/etc/cron.d/',
|
||||
'/var/www/fastuser/data/www/crm.clientright.ru/cron/',
|
||||
];
|
||||
|
||||
foreach ($cronFiles as $cronPath) {
|
||||
if (file_exists($cronPath)) {
|
||||
echo " Проверка: $cronPath\n";
|
||||
if (is_dir($cronPath)) {
|
||||
$files = glob($cronPath . '*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
$content = file_get_contents($file);
|
||||
if (stripos($content, 'delete') !== false || stripos($content, 'remove') !== false) {
|
||||
echo " Найден файл: $file\n";
|
||||
echo " Содержит упоминания удаления\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$content = file_get_contents($cronPath);
|
||||
if (stripos($content, 'delete') !== false || stripos($content, 'remove') !== false) {
|
||||
echo " Файл содержит упоминания удаления\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// Проверяем, может быть это связано с синхронизацией Nextcloud
|
||||
echo "4. Проверка связи с Nextcloud синхронизацией...\n";
|
||||
$nextcloudFiles = glob('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/*sync*.php');
|
||||
if (!empty($nextcloudFiles)) {
|
||||
echo " Найдено файлов синхронизации: " . count($nextcloudFiles) . "\n";
|
||||
foreach ($nextcloudFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if (stripos($content, 'delete') !== false) {
|
||||
echo " $file содержит код удаления\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo " Файлы синхронизации не найдены\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "ОШИБКА: " . $e->getMessage() . "\n";
|
||||
echo "Trace: " . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
@@ -248,3 +248,4 @@ if ($result && $result['success'] && isset($result['results'])) {
|
||||
], 500);
|
||||
}
|
||||
|
||||
|
||||
|
||||
518
browserless_login_esia.js
Normal file
518
browserless_login_esia.js
Normal file
@@ -0,0 +1,518 @@
|
||||
// Авторизация на ej.sudrf.ru через ЕСИА (Госуслуги)
|
||||
// n8n → HTTP Request → Browserless (Puppeteer)
|
||||
//
|
||||
// Вход: $credentials.login, $credentials.password
|
||||
// Выход: { status, cookies, screenshot, url, session_data }
|
||||
export default async function ({ page, context: browserContext }, input = {}) {
|
||||
// Варианты передачи логина/пароля:
|
||||
// 1) Предпочтительно: отдельными полями body запроса Browserless:
|
||||
// { code, login, pass } или { code, context: { login, pass } }
|
||||
// 2) Если тянете из предыдущей ноды прямо в поле code — используйте JSON.stringify, чтобы не ломать JS:
|
||||
// const login = {{ JSON.stringify($json.login) }};
|
||||
// const pass = {{ JSON.stringify($json.pass) }};
|
||||
|
||||
// Эти строки можно включить в n8n (expression), если вы не передаёте login/pass отдельными полями:
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const __FALLBACK_LOGIN__ = {{ JSON.stringify($json.login ?? "") }};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const __FALLBACK_PASS__ = {{ JSON.stringify($json.pass ?? "") }};
|
||||
|
||||
const fallbackLogin = (typeof __FALLBACK_LOGIN__ !== 'undefined') ? __FALLBACK_LOGIN__ : '';
|
||||
const fallbackPass = (typeof __FALLBACK_PASS__ !== 'undefined') ? __FALLBACK_PASS__ : '';
|
||||
|
||||
const login = String(input.login ?? input.context?.login ?? fallbackLogin ?? '').trim();
|
||||
const password = String(input.pass ?? input.password ?? input.context?.pass ?? input.context?.password ?? fallbackPass ?? '').trim();
|
||||
|
||||
if (!login || !password) {
|
||||
throw new Error('Не переданы login/pass во входных данных Browserless');
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const timeout = 45000;
|
||||
const loadDelay = 800;
|
||||
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.setDefaultTimeout(timeout);
|
||||
|
||||
const makeError = async (error_type, error_message, extra = {}) => {
|
||||
const bodyText = await page.evaluate(() => document.body?.innerText || '').catch(() => '');
|
||||
return {
|
||||
status: 'error',
|
||||
error_type,
|
||||
error_message,
|
||||
current_url: page.url(),
|
||||
page_text: bodyText.slice(0, 1500),
|
||||
screenshot: await page.screenshot({ encoding: 'base64', fullPage: true }),
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const isEsiaUrl = (u) =>
|
||||
(u || '').includes('esia.gosuslugi.ru') || (u || '').includes('gosuslugi.ru');
|
||||
|
||||
const waitEsiaOrNav = async (ms = 30000) => {
|
||||
await Promise.race([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: ms }).catch(() => {}),
|
||||
page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
location.href.includes('gosuslugi') ||
|
||||
location.href.includes('esia.gosuslugi'),
|
||||
{ timeout: ms }
|
||||
)
|
||||
.catch(() => {}),
|
||||
]);
|
||||
};
|
||||
|
||||
const clickByText = async (patterns) => {
|
||||
return page
|
||||
.evaluate((patterns) => {
|
||||
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const els = Array.from(
|
||||
document.querySelectorAll(
|
||||
'a, button, [role="button"], input[type="button"], input[type="submit"]'
|
||||
)
|
||||
);
|
||||
|
||||
const hit = els.find((el) => {
|
||||
const t = norm(el.textContent || el.value || '');
|
||||
const href = (el.getAttribute?.('href') || '').toLowerCase();
|
||||
return patterns.some((p) => t.includes(p) || href.includes(p));
|
||||
});
|
||||
|
||||
if (hit) {
|
||||
hit.scrollIntoView({ block: 'center' });
|
||||
hit.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, patterns.map((p) => p.toLowerCase()))
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
const clickBySelector = async (selector) => {
|
||||
const el = await page.$(selector).catch(() => null);
|
||||
if (!el) return false;
|
||||
await el.scrollIntoViewIfNeeded?.().catch(() => {});
|
||||
try {
|
||||
await el.click({ delay: 30 });
|
||||
return true;
|
||||
} catch (_) {}
|
||||
try {
|
||||
const box = await el.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { delay: 30 });
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Принять пользовательское соглашение + нажать "Войти" на ej.sudrf.ru
|
||||
const acceptAgreementAndLogin = async () => {
|
||||
// На живой странице выяснили:
|
||||
// - чекбокс имеет id="iAgree"
|
||||
// - кнопка "Войти" имеет классы: btn btn-primary esia-login esiaLogin и type="submit"
|
||||
// - кнопка реально disabled до отметки чекбокса
|
||||
|
||||
await page
|
||||
.waitForSelector('#iAgree', { visible: true, timeout: 20000 })
|
||||
.catch(() => {});
|
||||
|
||||
// 1) Отмечаем чекбокс реальным кликом (именно он включает кнопку)
|
||||
const checkboxClicked = await page
|
||||
.evaluate(() => {
|
||||
const cb = document.querySelector('#iAgree');
|
||||
if (!cb) return false;
|
||||
cb.scrollIntoView({ block: 'center' });
|
||||
|
||||
const label =
|
||||
cb.closest('label') ||
|
||||
(cb.id ? document.querySelector(`label[for="${cb.id}"]`) : null);
|
||||
|
||||
if (label) label.click();
|
||||
else cb.click();
|
||||
|
||||
cb.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
if (!checkboxClicked) {
|
||||
return { checkboxChecked: false, loginClicked: false };
|
||||
}
|
||||
|
||||
// 2) Ждём, что чекбокс действительно стал checked
|
||||
const checkboxChecked = await page
|
||||
.waitForFunction(() => !!document.querySelector('#iAgree')?.checked, { timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!checkboxChecked) {
|
||||
return { checkboxChecked: false, loginClicked: false };
|
||||
}
|
||||
|
||||
// 3) Ждём, что кнопка "Войти" стала enabled (disabled снят)
|
||||
const loginBtnSelector = 'button.esiaLogin, button.esia-login, button.btn.esiaLogin, button.btn.esia-login';
|
||||
const loginBtnReady = await page
|
||||
.waitForFunction(
|
||||
(sel) => {
|
||||
const btn = document.querySelector(sel);
|
||||
if (!btn) return false;
|
||||
// disabled должен быть снят
|
||||
// @ts-ignore
|
||||
if (btn.disabled === true) return false;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
},
|
||||
{ timeout: 25000 },
|
||||
loginBtnSelector
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!loginBtnReady) {
|
||||
return { checkboxChecked: true, loginClicked: false, reason: 'login_button_still_disabled' };
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
// 4) Кликаем "Войти" и ждём перехода на ЕСИА
|
||||
const beforeUrl = page.url();
|
||||
await Promise.all([
|
||||
waitEsiaOrNav(25000),
|
||||
page
|
||||
.click(loginBtnSelector, { delay: 30 })
|
||||
.catch(async () => {
|
||||
// фоллбек на DOM-click
|
||||
await page
|
||||
.evaluate((sel) => {
|
||||
const btn = document.querySelector(sel);
|
||||
if (btn) {
|
||||
// @ts-ignore
|
||||
btn.disabled = false;
|
||||
btn.removeAttribute?.('disabled');
|
||||
// @ts-ignore
|
||||
btn.click();
|
||||
}
|
||||
}, loginBtnSelector)
|
||||
.catch(() => {});
|
||||
}),
|
||||
]);
|
||||
|
||||
const afterUrl = page.url();
|
||||
const loginClicked = isEsiaUrl(afterUrl) || afterUrl !== beforeUrl;
|
||||
return { checkboxChecked: true, loginClicked, url: afterUrl };
|
||||
};
|
||||
|
||||
// ——— 1) Открываем ej.sudrf.ru ———
|
||||
await page.goto('https://ej.sudrf.ru/?fromOa=16RS0018', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout,
|
||||
});
|
||||
await sleep(loadDelay);
|
||||
|
||||
// Иногда редиректит сразу (редко), но обычно — нет
|
||||
let currentUrl = page.url();
|
||||
|
||||
if (!isEsiaUrl(currentUrl)) {
|
||||
// Подождать дорисовку страницы
|
||||
await page.waitForSelector('body', { timeout: 20000 }).catch(() => {});
|
||||
await sleep(400);
|
||||
|
||||
// ——— 2) Проверяем, на какой странице мы находимся ———
|
||||
const pageType = await page
|
||||
.evaluate(() => {
|
||||
const t = (document.body?.innerText || '').toLowerCase();
|
||||
if (t.includes('авторизация пользователя') && t.includes('есиа')) {
|
||||
return 'auth_page';
|
||||
}
|
||||
if (t.includes('обращения') || t.includes('дела')) {
|
||||
return 'main_page';
|
||||
}
|
||||
return 'unknown';
|
||||
})
|
||||
.catch(() => 'unknown');
|
||||
|
||||
if (pageType === 'main_page') {
|
||||
// ——— 2a) На главной странице — ищем кнопку "Вход" ———
|
||||
let loginLinkClicked = await clickByText(['вход']);
|
||||
|
||||
if (!loginLinkClicked) {
|
||||
// Пытаемся найти по селекторам
|
||||
loginLinkClicked = await page
|
||||
.evaluate(() => {
|
||||
const links = Array.from(document.querySelectorAll('a'));
|
||||
const loginLink = links.find((el) => {
|
||||
const text = (el.textContent || '').toLowerCase().trim();
|
||||
return text === 'вход';
|
||||
});
|
||||
if (loginLink) {
|
||||
loginLink.scrollIntoView({ block: 'center' });
|
||||
loginLink.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
if (!loginLinkClicked) {
|
||||
return await makeError('main_page_login_not_found', 'Не удалось найти кнопку "Вход" на главной странице');
|
||||
}
|
||||
|
||||
// Ждём загрузки страницы авторизации с дополнительными проверками
|
||||
await Promise.race([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }),
|
||||
page.waitForSelector('input[type="checkbox"]', { timeout: 15000 }),
|
||||
page.waitForFunction(() => document.body?.innerText?.toLowerCase().includes('авторизация пользователя'), { timeout: 15000 })
|
||||
]).catch(() => {});
|
||||
await sleep(loadDelay);
|
||||
}
|
||||
|
||||
// ——— 2b) Теперь должны быть на странице авторизации — принимаем соглашение и жмём Войти ———
|
||||
const pageLooksLikeAuth = await page
|
||||
.evaluate(() => {
|
||||
const t = (document.body?.innerText || '').toLowerCase();
|
||||
return t.includes('авторизация пользователя') && t.includes('есиа');
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
if (pageLooksLikeAuth) {
|
||||
const { checkboxState, loginClicked } = await acceptAgreementAndLogin();
|
||||
|
||||
// Если кнопка не нажалась (например, disabled) — ещё раз попробуем клик по "Войти" через общий поиск
|
||||
if (!loginClicked) {
|
||||
// Иногда кнопка активируется с задержкой после клика по чекбоксу
|
||||
await sleep(600);
|
||||
const fallbackLoginClick = await clickByText(['войти']);
|
||||
if (!fallbackLoginClick) {
|
||||
return await makeError(
|
||||
'esia_login_button_not_clicked',
|
||||
'Не удалось нажать "Войти" после принятия соглашения',
|
||||
{ checkboxState }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ждём редирект на ЕСИА
|
||||
await waitEsiaOrNav(30000);
|
||||
await sleep(loadDelay);
|
||||
} else {
|
||||
return await makeError('auth_page_not_found', 'Не удалось попасть на страницу авторизации');
|
||||
}
|
||||
}
|
||||
|
||||
// ——— 4) Проверяем, что мы на ЕСИА ———
|
||||
currentUrl = page.url();
|
||||
if (!isEsiaUrl(currentUrl)) {
|
||||
return await makeError('esia_redirect_failed', 'Не произошел редирект на ЕСИА (после Войти)', {
|
||||
after_actions_url: currentUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// ——— 5) Ввод логина на ЕСИА ———
|
||||
await page
|
||||
.waitForSelector('input[type="text"], input[name="login"], input[name="username"]', {
|
||||
timeout: 20000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// ЕСИА часто на React/контролируемых инпутах — простая установка el.value может не сработать.
|
||||
// Поэтому делаем "живой" ввод через клавиатуру + проверяем, что значение реально попало в input.value.
|
||||
const normalizePhone = (v) => String(v || '').trim().replace(/^\+/, '');
|
||||
const loginToType = normalizePhone(login);
|
||||
|
||||
const fillInput = async (selectors, value, debugKey) => {
|
||||
// 1) Выбираем ВИДИМЫЙ инпут (у ESIA часто есть скрытые дубли)
|
||||
for (const sel of selectors) {
|
||||
const handles = await page.$$(sel).catch(() => []);
|
||||
for (const handle of handles) {
|
||||
try {
|
||||
const box = await handle.boundingBox();
|
||||
if (!box || box.width < 5 || box.height < 5) continue;
|
||||
|
||||
// запомним, какой селектор реально сработал (для отладки)
|
||||
if (debugKey) {
|
||||
debug[debugKey] = { selector: sel, box };
|
||||
}
|
||||
|
||||
await handle.focus();
|
||||
// очистка
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('KeyA');
|
||||
await page.keyboard.up('Control');
|
||||
await page.keyboard.press('Backspace');
|
||||
|
||||
// ввод именно через elementHandle.type (иногда надежнее чем page.keyboard.type)
|
||||
await handle.type(String(value), { delay: 60 });
|
||||
|
||||
// blur + change (нужно ESIA, иначе пишет "Заполните поле")
|
||||
await handle.evaluate((el) => {
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.blur();
|
||||
}).catch(() => {});
|
||||
|
||||
await sleep(250);
|
||||
|
||||
const ok = await handle
|
||||
.evaluate((el) => typeof el.value === 'string' && el.value.length > 0)
|
||||
.catch(() => false);
|
||||
if (ok) return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Фоллбек: нативный setter + input/change (для React)
|
||||
const ok = await page
|
||||
.evaluate((sels, val) => {
|
||||
const isVisible = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 5 && r.height > 5;
|
||||
};
|
||||
const pick = () => {
|
||||
for (const s of sels) {
|
||||
const list = Array.from(document.querySelectorAll(s));
|
||||
const visible = list.find(isVisible);
|
||||
if (visible) return visible;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const el = pick();
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
if (setter) {
|
||||
setter.call(el, '');
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
setter.call(el, String(val));
|
||||
} else {
|
||||
// @ts-ignore
|
||||
el.value = String(val);
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.blur();
|
||||
// @ts-ignore
|
||||
return typeof el.value === 'string' && el.value.length > 0;
|
||||
}, selectors, value)
|
||||
.catch(() => false);
|
||||
return ok;
|
||||
};
|
||||
|
||||
const debug = {};
|
||||
|
||||
const loginFilled = await fillInput(
|
||||
[
|
||||
'input[name="login"]',
|
||||
'input[name="username"]',
|
||||
'input[type="tel"]',
|
||||
'input[type="text"]',
|
||||
],
|
||||
loginToType,
|
||||
'loginInput'
|
||||
);
|
||||
|
||||
if (!loginFilled) {
|
||||
return await makeError('login_input_not_found', 'Не найдено поле логина на странице ЕСИА');
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
|
||||
// ——— 6) Ввод пароля ———
|
||||
await page.waitForSelector('input[type="password"]', { timeout: 20000 }).catch(() => {});
|
||||
const passFilled = await fillInput(['input[type="password"]'], password, 'passwordInput');
|
||||
|
||||
if (!passFilled) {
|
||||
return await makeError('password_input_not_found', 'Не найдено поле пароля на странице ЕСИА');
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
|
||||
// ——— 7) Submit ———
|
||||
// маленькая пауза перед сабмитом, чтобы ESIA "съела" input/change
|
||||
await sleep(600);
|
||||
|
||||
const submitted = await page
|
||||
.evaluate(() => {
|
||||
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const btn =
|
||||
document.querySelector('button[type="submit"]') ||
|
||||
Array.from(document.querySelectorAll('button')).find((b) =>
|
||||
norm(b.textContent).includes('войти')
|
||||
) ||
|
||||
Array.from(document.querySelectorAll('input[type="submit"]')).find(Boolean);
|
||||
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center' });
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
if (!submitted) {
|
||||
// fallback Enter
|
||||
await page.keyboard.press('Enter').catch(() => {});
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
// ——— 8) Ждём SMS-поля (или навигацию) ———
|
||||
const otpSelector =
|
||||
'input[inputmode="numeric"], input[type="tel"], input[autocomplete="one-time-code"], input[name="otp"], input[name*="code"], input[id*="otp"], input[id*="code"]';
|
||||
|
||||
await Promise.race([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
|
||||
page
|
||||
.waitForFunction(
|
||||
(sel) => document.querySelectorAll(sel).length > 0,
|
||||
{ timeout: 30000 },
|
||||
otpSelector
|
||||
)
|
||||
.catch(() => {}),
|
||||
]);
|
||||
|
||||
await sleep(loadDelay);
|
||||
|
||||
const smsInputs = await page.$$(otpSelector).catch(() => []);
|
||||
if (!smsInputs || smsInputs.length === 0) {
|
||||
// Если мы всё ещё на /login/, скорее всего форма не приняла пароль/логин или показала валидацию
|
||||
const urlNow = page.url();
|
||||
if (urlNow.includes('esia.gosuslugi.ru/login')) {
|
||||
return await makeError(
|
||||
'login_failed',
|
||||
'После нажатия «Войти» ЕСИА не перешла к SMS. Скорее всего, форма считает логин/пароль пустыми или произошла ошибка входа.',
|
||||
{
|
||||
debug,
|
||||
}
|
||||
);
|
||||
}
|
||||
return await makeError(
|
||||
'sms_page_not_found',
|
||||
'Не найдены поля для ввода SMS кода (возможно, иной фактор подтверждения или ошибка входа)'
|
||||
);
|
||||
}
|
||||
|
||||
// ——— 9) Сохраняем куки + скрин ———
|
||||
// В browserless /function это Puppeteer: куки берём с page
|
||||
const cookies = (typeof page.cookies === 'function') ? await page.cookies() : [];
|
||||
const screenshot = await page.screenshot({ encoding: 'base64', fullPage: true });
|
||||
|
||||
return {
|
||||
status: 'waiting_for_sms',
|
||||
message: '✅ Дошли до ввода SMS. Ожидание кода.',
|
||||
url: page.url(),
|
||||
cookies,
|
||||
screenshot,
|
||||
sms_inputs_count: smsInputs.length,
|
||||
session_data: {
|
||||
created_at: new Date().toISOString(),
|
||||
note: 'cookies передай во второй скрипт, чтобы продолжить сессию и ввести SMS',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,53 @@ try {
|
||||
|
||||
error_log("Callback: Updated task {$taskId}, affected rows: {$affected}");
|
||||
|
||||
// Публикуем событие в Redis для мгновенной доставки через SSE
|
||||
try {
|
||||
if (class_exists('Redis')) {
|
||||
$redis = new Redis();
|
||||
if ($redis->connect('crm.clientright.ru', 6379)) {
|
||||
$redis->auth('CRM_Redis_Pass_2025_Secure!');
|
||||
|
||||
$channel = "ai:response:{$taskId}";
|
||||
$event = json_encode([
|
||||
'task_id' => $taskId,
|
||||
'status' => $status,
|
||||
'response' => $response,
|
||||
'error' => $error,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$redis->publish($channel, $event);
|
||||
error_log("Callback: Published to Redis channel {$channel}");
|
||||
$redis->close();
|
||||
}
|
||||
} else {
|
||||
// Используем Predis если расширение Redis недоступно
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
$redis = new Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => 'crm.clientright.ru',
|
||||
'port' => 6379,
|
||||
'password' => 'CRM_Redis_Pass_2025_Secure!',
|
||||
]);
|
||||
|
||||
$channel = "ai:response:{$taskId}";
|
||||
$event = json_encode([
|
||||
'task_id' => $taskId,
|
||||
'status' => $status,
|
||||
'response' => $response,
|
||||
'error' => $error,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$redis->publish($channel, $event);
|
||||
error_log("Callback: Published to Redis channel {$channel} via Predis");
|
||||
}
|
||||
} catch (Exception $redisError) {
|
||||
error_log("Callback: Redis publish error (non-critical): " . $redisError->getMessage());
|
||||
// Не прерываем выполнение, если Redis недоступен - БД уже обновлена
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Response received',
|
||||
|
||||
51
check_docs_filename_371231.php
Normal file
51
check_docs_filename_371231.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projectId = 371231;
|
||||
|
||||
$sql = "SELECT
|
||||
n.notesid,
|
||||
n.title,
|
||||
n.filelocationtype,
|
||||
n.filename,
|
||||
n.s3_bucket,
|
||||
n.s3_key
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0
|
||||
ORDER BY n.notesid DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$projectId]);
|
||||
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "Проверка поля filename для документов проекта $projectId\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
foreach ($documents as $doc) {
|
||||
echo "ID: {$doc['notesid']}\n";
|
||||
echo " Название: {$doc['title']}\n";
|
||||
echo " filelocationtype: {$doc['filelocationtype']}\n";
|
||||
echo " filename (первые 200 символов): " . substr($doc['filename'], 0, 200) . "\n";
|
||||
echo " s3_bucket: " . ($doc['s3_bucket'] ?? 'нет') . "\n";
|
||||
echo " s3_key: " . substr($doc['s3_key'] ?? 'нет', 0, 100) . "\n";
|
||||
|
||||
// Проверяем, является ли filename URL
|
||||
$isUrl = filter_var($doc['filename'], FILTER_VALIDATE_URL);
|
||||
echo " filename является URL: " . ($isUrl ? 'ДА' : 'НЕТ') . "\n";
|
||||
|
||||
// Проверяем, начинается ли filename с http
|
||||
$isHttp = (strpos($doc['filename'], 'http://') === 0 || strpos($doc['filename'], 'https://') === 0);
|
||||
echo " filename начинается с http: " . ($isHttp ? 'ДА' : 'НЕТ') . "\n";
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
149
check_project_371231.php
Normal file
149
check_project_371231.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projectId = 371231;
|
||||
|
||||
// Получаем информацию о проекте
|
||||
$sqlProject = "SELECT projectid, projectname, projectstatus FROM vtiger_project WHERE projectid = ?";
|
||||
$stmtProject = $pdo->prepare($sqlProject);
|
||||
$stmtProject->execute([$projectId]);
|
||||
$project = $stmtProject->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project) {
|
||||
die("❌ Проект $projectId не найден!\n");
|
||||
}
|
||||
|
||||
echo "📋 ПРОЕКТ: {$project['projectname']}\n";
|
||||
echo " ID: {$project['projectid']}\n";
|
||||
echo " Статус: {$project['projectstatus']}\n";
|
||||
echo "\n" . str_repeat("=", 80) . "\n\n";
|
||||
|
||||
// Получаем документы проекта
|
||||
$sql = "SELECT
|
||||
n.notesid,
|
||||
n.title,
|
||||
n.filename,
|
||||
n.filelocationtype,
|
||||
n.foldername,
|
||||
n.s3_bucket,
|
||||
n.s3_key,
|
||||
n.nc_path,
|
||||
n.filesize,
|
||||
e.createdtime,
|
||||
e.modifiedtime,
|
||||
u.user_name,
|
||||
e.deleted
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
LEFT JOIN vtiger_users u ON u.id = e.smownerid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0
|
||||
ORDER BY e.createdtime DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$projectId]);
|
||||
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$count = count($documents);
|
||||
echo "📄 НАЙДЕНО ДОКУМЕНТОВ: $count\n\n";
|
||||
|
||||
if ($count == 0) {
|
||||
echo "⚠️ Документы не найдены!\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
$s3Count = 0;
|
||||
$localCount = 0;
|
||||
$brokenCount = 0;
|
||||
$accessibleCount = 0;
|
||||
$notAccessibleCount = 0;
|
||||
|
||||
foreach ($documents as $i => $doc) {
|
||||
$num = $i + 1;
|
||||
$filelocationtype = $doc['filelocationtype'] ?? 'I';
|
||||
$s3Bucket = $doc['s3_bucket'] ?? null;
|
||||
$s3Key = $doc['s3_key'] ?? null;
|
||||
$filename = $doc['filename'] ?? '';
|
||||
$title = $doc['title'] ?? 'Без названия';
|
||||
|
||||
$isS3 = ($filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key));
|
||||
$isLocal = ($filelocationtype == 'I' || empty($filelocationtype));
|
||||
|
||||
if ($isS3) {
|
||||
$s3Count++;
|
||||
$status = "☁️ S3";
|
||||
$filePath = "s3://{$s3Bucket}/{$s3Key}";
|
||||
$accessibleCount++; // Пока считаем доступными, проверим отдельно
|
||||
|
||||
} else {
|
||||
$localCount++;
|
||||
$status = "💾 Локальный";
|
||||
|
||||
// Для локальных файлов проверяем путь
|
||||
if (!empty($filename)) {
|
||||
// Парсим путь из filename
|
||||
$filePath = $filename;
|
||||
if (file_exists($filePath)) {
|
||||
$accessibleCount++;
|
||||
$status .= " ✅";
|
||||
} else {
|
||||
$notAccessibleCount++;
|
||||
$brokenCount++;
|
||||
$status .= " ❌ ФАЙЛ НЕ НАЙДЕН";
|
||||
}
|
||||
} else {
|
||||
$notAccessibleCount++;
|
||||
$brokenCount++;
|
||||
$status .= " ❌ НЕТ ПУТИ";
|
||||
}
|
||||
}
|
||||
|
||||
$size = $doc['filesize'] ?? 0;
|
||||
$totalSize += $size;
|
||||
$sizeStr = $size > 0 ? number_format($size / 1024, 2) . ' KB' : '0 KB';
|
||||
|
||||
echo sprintf(
|
||||
"%3d. [%s] %s\n",
|
||||
$num,
|
||||
$status,
|
||||
$title
|
||||
);
|
||||
echo sprintf(
|
||||
" ID: %d | Размер: %s | Тип: %s\n",
|
||||
$doc['notesid'],
|
||||
$sizeStr,
|
||||
$filelocationtype
|
||||
);
|
||||
|
||||
if ($isS3) {
|
||||
echo sprintf(" S3 Key: %s\n", $s3Key);
|
||||
} else {
|
||||
echo sprintf(" Путь: %s\n", substr($filename, 0, 100));
|
||||
}
|
||||
|
||||
if ($notAccessibleCount > 0 && ($i == $count - 1 || ($i + 1) % 10 == 0)) {
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat("=", 80) . "\n";
|
||||
echo "📊 СТАТИСТИКА:\n";
|
||||
echo " Всего документов: $count\n";
|
||||
echo " S3 документов: $s3Count\n";
|
||||
echo " Локальных документов: $localCount\n";
|
||||
echo " Доступных: $accessibleCount ✅\n";
|
||||
echo " Недоступных: $notAccessibleCount ❌\n";
|
||||
echo " Общий размер: " . number_format($totalSize / 1024 / 1024, 2) . " MB\n";
|
||||
|
||||
if ($brokenCount > 0) {
|
||||
echo "\n⚠️ ВНИМАНИЕ: Найдено $brokenCount недоступных файлов!\n";
|
||||
}
|
||||
|
||||
51
check_project_371231_simple.php
Normal file
51
check_project_371231_simple.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projectId = 371231;
|
||||
|
||||
// Получаем документы проекта
|
||||
$sql = "SELECT
|
||||
n.notesid,
|
||||
n.title,
|
||||
n.filename,
|
||||
n.filelocationtype,
|
||||
n.s3_bucket,
|
||||
n.s3_key,
|
||||
n.filesize
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0
|
||||
ORDER BY e.createdtime DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$projectId]);
|
||||
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "Найдено документов: " . count($documents) . "\n\n";
|
||||
|
||||
foreach ($documents as $i => $doc) {
|
||||
$num = $i + 1;
|
||||
echo "$num. ID: {$doc['notesid']}\n";
|
||||
echo " Название: " . ($doc['title'] ?? 'Нет') . "\n";
|
||||
echo " Тип хранения: " . ($doc['filelocationtype'] ?? 'I') . "\n";
|
||||
|
||||
if ($doc['filelocationtype'] == 'E') {
|
||||
echo " S3 Bucket: " . ($doc['s3_bucket'] ?? 'нет') . "\n";
|
||||
echo " S3 Key: " . ($doc['s3_key'] ?? 'нет') . "\n";
|
||||
} else {
|
||||
echo " Filename: " . substr($doc['filename'] ?? 'нет', 0, 100) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
129
check_project_373977.php
Normal file
129
check_project_373977.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Проверка документов проекта 373977
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
|
||||
$projectId = 373977;
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
|
||||
// Документы проекта из БД
|
||||
$documents = [
|
||||
373981 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/8_Договор_на_оказание_услуг_373981.pdf',
|
||||
373983 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/9_Подтверждение_оплаты_по_договору_373983.pdf',
|
||||
373985 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/10_2_Скрин_личного_кабинета_Истца_и_программа_обуч_373985.pdf',
|
||||
373987 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/10_1_Скрин_личного_кабинета_Истца_и_программа_обуч_373987.pdf',
|
||||
373989 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11_1_Подтверждение_проведения_претензионной_работы_373989.pdf',
|
||||
373991 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/7_заявление_потребителя_373991.pdf',
|
||||
374017 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11_Доказательство_соблюдения_претензионного_порядк_374017.pdf',
|
||||
375402 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.2_Претензия_в_защиту_интересов_Полулях_Ольга_1_375402.pdf',
|
||||
375404 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.3_Доказательство_оплаты_направления_претензии_о_375404.pdf',
|
||||
375406 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.4_Доказательство_направления_претензии_ответчик_375406.pdf',
|
||||
376051 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/0_Исковое_заявление_по_делу_Полулях_7_стр_376051.pdf',
|
||||
376054 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/6_Расчет_исковых_требований_Полулях_1_стр_376054.pdf',
|
||||
376080 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/12.1_Доказательство_оплаты_направления_иска_ответч_376080.pdf',
|
||||
376082 => 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/12.2_Доказательство_направления_иска_ответчику_376082.pdf',
|
||||
396623 => 'crm2/CRM_Active_Files/Documents/396623/ПК_451a1058-ee34-0d48-b2f4-d6dfa522928a.pdf_WITH_ENVELOPE.pdf', // Неправильное место!
|
||||
];
|
||||
|
||||
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => count($documents),
|
||||
'exists' => 0,
|
||||
'missing' => 0,
|
||||
'wrong_place' => 0,
|
||||
'missing_files' => [],
|
||||
'wrong_place_files' => [],
|
||||
];
|
||||
|
||||
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/Полулях_ЧУ_ДПО_ГОРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/';
|
||||
|
||||
foreach ($documents as $docId => $s3Key) {
|
||||
$filename = basename($s3Key);
|
||||
$isInProjectFolder = strpos($s3Key, $projectPrefix) === 0;
|
||||
|
||||
echo "Документ ID: {$docId}\n";
|
||||
echo " Файл: {$filename}\n";
|
||||
echo " Путь: {$s3Key}\n";
|
||||
|
||||
if ($s3Client->doesObjectExist($s3Bucket, $s3Key)) {
|
||||
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $s3Key]);
|
||||
$size = round($object['ContentLength'] / 1024, 2);
|
||||
|
||||
if (!$isInProjectFolder) {
|
||||
echo " ⚠️ Файл существует, но в неправильном месте (размер: {$size} KB)\n";
|
||||
$stats['wrong_place']++;
|
||||
$stats['wrong_place_files'][] = [
|
||||
'doc_id' => $docId,
|
||||
'current_path' => $s3Key,
|
||||
'should_be' => $projectPrefix . $filename,
|
||||
];
|
||||
} else {
|
||||
echo " ✅ Файл существует (размер: {$size} KB)\n";
|
||||
$stats['exists']++;
|
||||
}
|
||||
} else {
|
||||
echo " ❌ Файл отсутствует\n";
|
||||
$stats['missing']++;
|
||||
$stats['missing_files'][] = [
|
||||
'doc_id' => $docId,
|
||||
'path' => $s3Key,
|
||||
];
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Итоги
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГИ:\n";
|
||||
echo "Всего документов: {$stats['total']}\n";
|
||||
echo "✅ На месте: {$stats['exists']}\n";
|
||||
echo "⚠️ В неправильном месте: {$stats['wrong_place']}\n";
|
||||
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
|
||||
|
||||
if (!empty($stats['wrong_place_files'])) {
|
||||
echo "ФАЙЛЫ В НЕПРАВИЛЬНОМ МЕСТЕ:\n";
|
||||
foreach ($stats['wrong_place_files'] as $file) {
|
||||
echo " - Документ {$file['doc_id']}: {$file['current_path']}\n";
|
||||
echo " Должен быть: {$file['should_be']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
if (!empty($stats['missing_files'])) {
|
||||
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
|
||||
foreach ($stats['missing_files'] as $file) {
|
||||
echo " - Документ {$file['doc_id']}: {$file['path']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "=== ГОТОВО ===\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "❌ Ошибка: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
103
check_project_391584.php
Normal file
103
check_project_391584.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Проверка документов проекта 391584
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
|
||||
$projectId = 391584;
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/Чужба_ЧОУ_ДПО_ОБРАЗОВАТЕЛЬНЫЕ_ТЕХНОЛОГИИ_СКИЛБОКС_(КОРОБКА_НАВЫКОВ)_391584/';
|
||||
|
||||
// Документы проекта из БД
|
||||
$documents = [
|
||||
391587 => '8_Договор_на_оказание_услуг_391587.pdf',
|
||||
391589 => '9_Подтверждение_оплаты_по_договору_391589.pdf',
|
||||
391591 => '10_1_Скрин_личного_кабинета_Истца_и_программа_обуч_391591.pdf',
|
||||
391593 => '7_заявление_потребителя_391593.pdf',
|
||||
392332 => '11_Доказательство_соблюдения_претензионного_порядк_392332.pdf',
|
||||
392472 => '11.1_Доказательство_соблюдения_претензионного_поря_392472.pdf',
|
||||
392475 => '11.2_Доказательство_соблюдения_претензионного_поря_392475.pdf',
|
||||
395136 => '6_Расчет_иска_Чужба_395136.pdf',
|
||||
395157 => '0_Исковое_заявление_по_делу_Чужба_ЧОУ_ДПО_ОБРАЗОВА_395157.pdf',
|
||||
395744 => '12.1_Доказательство_оплаты_направления_иска_ответч_395744.pdf',
|
||||
];
|
||||
|
||||
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => count($documents),
|
||||
'exists' => 0,
|
||||
'missing' => 0,
|
||||
'missing_files' => [],
|
||||
];
|
||||
|
||||
foreach ($documents as $docId => $filename) {
|
||||
$s3Key = $projectPrefix . $filename;
|
||||
|
||||
echo "Документ ID: {$docId}\n";
|
||||
echo " Файл: {$filename}\n";
|
||||
echo " Путь: {$s3Key}\n";
|
||||
|
||||
$exists = $s3Client->doesObjectExist($s3Bucket, $s3Key);
|
||||
|
||||
if ($exists) {
|
||||
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $s3Key]);
|
||||
$size = round($object['ContentLength'] / 1024, 2);
|
||||
echo " ✅ Файл существует (размер: {$size} KB)\n";
|
||||
$stats['exists']++;
|
||||
} else {
|
||||
echo " ❌ Файл отсутствует\n";
|
||||
$stats['missing']++;
|
||||
$stats['missing_files'][] = [
|
||||
'doc_id' => $docId,
|
||||
'filename' => $filename,
|
||||
'path' => $s3Key,
|
||||
];
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Итоги
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГИ:\n";
|
||||
echo "Всего документов: {$stats['total']}\n";
|
||||
echo "✅ Существуют: {$stats['exists']}\n";
|
||||
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
|
||||
|
||||
if (!empty($stats['missing_files'])) {
|
||||
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
|
||||
foreach ($stats['missing_files'] as $file) {
|
||||
echo " - Документ {$file['doc_id']}: {$file['filename']}\n";
|
||||
echo " Путь: {$file['path']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "=== ГОТОВО ===\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "❌ Ошибка: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
158
check_project_398027.php
Normal file
158
check_project_398027.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* Проверка документов проекта 398027
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
|
||||
$projectId = 398027;
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/Храмов_ООО_НЕТОЛОГИЯ_398027/';
|
||||
|
||||
// Документы проекта из БД
|
||||
$documents = [
|
||||
398030 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398030/8_Договор_на_оказание_услуг_11-14-2025-16-00-51_Храмов_1_CTP#realfile.pdf',
|
||||
'should_be' => $projectPrefix . '8_Договор_на_оказание_услуг_398030.pdf',
|
||||
],
|
||||
398032 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398032/9_Подтверждение_оплаты_по_договору_11-14-2025-16-00-03_Храмов_1_CTP#realfile.pdf',
|
||||
'should_be' => $projectPrefix . '9_Подтверждение_оплаты_по_договору_398032.pdf',
|
||||
],
|
||||
398034 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398034/10_2_Скрин_личного_кабинета_Истца_и_программа_обучения_11-14-2025-15-47-26_Храмов_41_CTP#realfile.pdf',
|
||||
'should_be' => $projectPrefix . '10_2_Скрин_личного_кабинета_Истца_и_программа_обучения_398034.pdf',
|
||||
],
|
||||
398036 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398036/10_1_Скрин_личного_кабинета_Истца_и_программа_обучения_11-14-2025-15-49-59_Храмов_1_CTP#realfile.pdf',
|
||||
'should_be' => $projectPrefix . '10_1_Скрин_личного_кабинета_Истца_и_программа_обучения_398036.pdf',
|
||||
],
|
||||
398038 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398038/Прочие_документы_11-14-2025-16-06-07_Храмов_3_CTP#realfile.pdf',
|
||||
'should_be' => $projectPrefix . 'Прочие_документы_398038.pdf',
|
||||
],
|
||||
398040 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398040/7_zayavlenie_potrebitelya_Hramov.pdf',
|
||||
'should_be' => $projectPrefix . '7_заявление_потребителя_398040.pdf',
|
||||
],
|
||||
398063 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/398063/napravleniya_pretenzii.pdf',
|
||||
'should_be' => $projectPrefix . 'Направление_претензии_398063.pdf',
|
||||
],
|
||||
398584 => [
|
||||
'current_path' => 'crm2/CRM_Active_Files/Documents/Project/Храмов_ООО_НЕТОЛОГИЯ_398027/8_Договор_на_оказание_услуг_398584.pdf',
|
||||
'should_be' => $projectPrefix . '8_Договор_на_оказание_услуг_398584.pdf',
|
||||
],
|
||||
399067 => [
|
||||
'current_path' => 'clientright/0/1763997676315.pdf',
|
||||
'should_be' => $projectPrefix . 'Документ_399067.pdf',
|
||||
],
|
||||
399068 => [
|
||||
'current_path' => 'clientright/0/1763997790309.pdf',
|
||||
'should_be' => $projectPrefix . 'Документ_399068.pdf',
|
||||
],
|
||||
];
|
||||
|
||||
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => count($documents),
|
||||
'exists_correct' => 0,
|
||||
'exists_wrong' => 0,
|
||||
'missing' => 0,
|
||||
'wrong_place_files' => [],
|
||||
'missing_files' => [],
|
||||
];
|
||||
|
||||
foreach ($documents as $docId => $paths) {
|
||||
$currentPath = $paths['current_path'];
|
||||
$shouldBe = $paths['should_be'];
|
||||
$filename = basename($shouldBe);
|
||||
|
||||
echo "Документ ID: {$docId}\n";
|
||||
echo " Файл: {$filename}\n";
|
||||
echo " Текущий путь: {$currentPath}\n";
|
||||
echo " Должен быть: {$shouldBe}\n";
|
||||
|
||||
$existsCurrent = $s3Client->doesObjectExist($s3Bucket, $currentPath);
|
||||
$existsCorrect = $s3Client->doesObjectExist($s3Bucket, $shouldBe);
|
||||
|
||||
if ($existsCorrect) {
|
||||
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $shouldBe]);
|
||||
$size = round($object['ContentLength'] / 1024, 2);
|
||||
echo " ✅ Файл уже в правильном месте (размер: {$size} KB)\n";
|
||||
$stats['exists_correct']++;
|
||||
} elseif ($existsCurrent) {
|
||||
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $currentPath]);
|
||||
$size = round($object['ContentLength'] / 1024, 2);
|
||||
echo " ⚠️ Файл существует, но в неправильном месте (размер: {$size} KB)\n";
|
||||
$stats['exists_wrong']++;
|
||||
$stats['wrong_place_files'][] = [
|
||||
'doc_id' => $docId,
|
||||
'current_path' => $currentPath,
|
||||
'should_be' => $shouldBe,
|
||||
];
|
||||
} else {
|
||||
echo " ❌ Файл отсутствует\n";
|
||||
$stats['missing']++;
|
||||
$stats['missing_files'][] = [
|
||||
'doc_id' => $docId,
|
||||
'path' => $currentPath,
|
||||
];
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Итоги
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГИ:\n";
|
||||
echo "Всего документов: {$stats['total']}\n";
|
||||
echo "✅ На месте: {$stats['exists_correct']}\n";
|
||||
echo "⚠️ В неправильном месте: {$stats['exists_wrong']}\n";
|
||||
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
|
||||
|
||||
if (!empty($stats['wrong_place_files'])) {
|
||||
echo "ФАЙЛЫ В НЕПРАВИЛЬНОМ МЕСТЕ:\n";
|
||||
foreach ($stats['wrong_place_files'] as $file) {
|
||||
echo " - Документ {$file['doc_id']}\n";
|
||||
echo " От: {$file['current_path']}\n";
|
||||
echo " К: {$file['should_be']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
if (!empty($stats['missing_files'])) {
|
||||
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
|
||||
foreach ($stats['missing_files'] as $file) {
|
||||
echo " - Документ {$file['doc_id']}: {$file['path']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "=== ГОТОВО ===\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "❌ Ошибка: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
172
check_project_files_access.php
Normal file
172
check_project_files_access.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
$projectId = 384256;
|
||||
$s3Bucket = $config['s3']['bucket'];
|
||||
|
||||
echo "Проверка доступности всех файлов проекта $projectId\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
try {
|
||||
// Инициализация S3 клиента
|
||||
$s3Client = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $config['s3']['region'],
|
||||
'endpoint' => $config['s3']['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => $config['s3']['key'],
|
||||
'secret' => $config['s3']['secret'],
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
// Подключение к БД
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
// Получаем все документы проекта
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT n.notesid, n.title, n.s3_key, n.filename, n.filelocationtype
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0
|
||||
ORDER BY n.notesid ASC
|
||||
');
|
||||
$stmt->execute([$projectId]);
|
||||
$docs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "Всего документов в проекте: " . count($docs) . "\n\n";
|
||||
|
||||
$accessible = [];
|
||||
$notAccessible = [];
|
||||
|
||||
foreach ($docs as $doc) {
|
||||
$docId = $doc['notesid'];
|
||||
$title = $doc['title'];
|
||||
$s3Key = $doc['s3_key'];
|
||||
$filelocationtype = $doc['filelocationtype'];
|
||||
|
||||
echo "ID: $docId | $title\n";
|
||||
|
||||
if ($filelocationtype == 'E' && !empty($s3Key)) {
|
||||
// Проверяем доступность в S3
|
||||
try {
|
||||
$result = $s3Client->headObject([
|
||||
'Bucket' => $s3Bucket,
|
||||
'Key' => $s3Key
|
||||
]);
|
||||
|
||||
$size = number_format($result['ContentLength'] / 1024, 2);
|
||||
echo " ✅ Доступен в S3 (" . $size . " KB)\n";
|
||||
echo " Путь: $s3Key\n";
|
||||
$accessible[] = ['doc' => $doc, 'size' => $result['ContentLength']];
|
||||
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
if ($e->getAwsErrorCode() == 'NotFound') {
|
||||
echo " ❌ НЕ найден в S3\n";
|
||||
echo " Ожидаемый путь: $s3Key\n";
|
||||
$notAccessible[] = $doc;
|
||||
} else {
|
||||
echo " ⚠️ Ошибка доступа: " . $e->getAwsErrorCode() . "\n";
|
||||
$notAccessible[] = $doc;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo " ⚠️ Тип хранения: " . ($filelocationtype ?: 'не указан') . "\n";
|
||||
if (!empty($doc['filename'])) {
|
||||
echo " Filename: " . substr($doc['filename'], 0, 100) . "\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "СТАТИСТИКА:\n";
|
||||
echo " Доступных файлов: " . count($accessible) . "\n";
|
||||
echo " Недоступных файлов: " . count($notAccessible) . "\n\n";
|
||||
|
||||
// Поиск недоступных файлов в других местах S3
|
||||
if (!empty($notAccessible)) {
|
||||
echo "Поиск недоступных файлов в других местах S3...\n\n";
|
||||
|
||||
foreach ($notAccessible as $doc) {
|
||||
$docId = $doc['notesid'];
|
||||
$title = $doc['title'];
|
||||
|
||||
echo "Поиск файла для документа $docId: $title\n";
|
||||
|
||||
// Ищем по ID документа в разных местах
|
||||
$searchPatterns = [
|
||||
"temp/$projectId/",
|
||||
"temp/",
|
||||
"crm2/CRM_Active_Files/Documents/",
|
||||
"Documents/",
|
||||
];
|
||||
|
||||
$found = false;
|
||||
foreach ($searchPatterns as $prefix) {
|
||||
try {
|
||||
$objects = $s3Client->listObjectsV2([
|
||||
'Bucket' => $s3Bucket,
|
||||
'Prefix' => $prefix,
|
||||
'MaxKeys' => 1000
|
||||
]);
|
||||
|
||||
if (isset($objects['Contents'])) {
|
||||
foreach ($objects['Contents'] as $object) {
|
||||
$key = $object['Key'];
|
||||
|
||||
// Ищем файлы, содержащие ID документа или похожие названия
|
||||
if (strpos($key, (string)$docId) !== false ||
|
||||
strpos($key, (string)($docId - 1)) !== false ||
|
||||
strpos($key, (string)($docId + 1)) !== false) {
|
||||
|
||||
// Проверяем доступность
|
||||
try {
|
||||
$headResult = $s3Client->headObject([
|
||||
'Bucket' => $s3Bucket,
|
||||
'Key' => $key
|
||||
]);
|
||||
|
||||
echo " ✅ НАЙДЕН: $key\n";
|
||||
echo " Размер: " . number_format($headResult['ContentLength'] / 1024, 2) . " KB\n";
|
||||
echo " Дата: " . ($headResult['LastModified'] ?? 'не указана') . "\n";
|
||||
|
||||
// Предлагаем переместить
|
||||
echo " 💡 Рекомендация: переместить в правильный путь\n";
|
||||
$found = true;
|
||||
break 2;
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
// Пропускаем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
// Пропускаем ошибки
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
echo " ❌ Файл не найден ни в одном месте S3\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "ОШИБКА: " . $e->getMessage() . "\n";
|
||||
echo "Trace: " . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
116
check_s3_access_371231.php
Normal file
116
check_s3_access_371231.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projectId = 371231;
|
||||
|
||||
// Получаем документы проекта
|
||||
$sql = "SELECT
|
||||
n.notesid,
|
||||
n.title,
|
||||
n.filename,
|
||||
n.filelocationtype,
|
||||
n.s3_bucket,
|
||||
n.s3_key
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0 AND n.filelocationtype = 'E'
|
||||
ORDER BY n.notesid DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$projectId]);
|
||||
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "Проверка доступности S3 файлов для проекта $projectId\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
// Инициализируем S3 клиент
|
||||
$s3Config = $config['s3'];
|
||||
$awsClient = new \Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $s3Config['region'],
|
||||
'endpoint' => $s3Config['endpoint'],
|
||||
'credentials' => [
|
||||
'key' => $s3Config['key'],
|
||||
'secret' => $s3Config['secret'],
|
||||
],
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
$accessible = 0;
|
||||
$notAccessible = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($documents as $doc) {
|
||||
$notesid = $doc['notesid'];
|
||||
$title = $doc['title'];
|
||||
$s3Bucket = $doc['s3_bucket'];
|
||||
$s3Key = $doc['s3_key'];
|
||||
|
||||
try {
|
||||
$exists = $awsClient->doesObjectExist($s3Bucket, $s3Key);
|
||||
|
||||
if ($exists) {
|
||||
$accessible++;
|
||||
echo "✅ ID: $notesid - $title\n";
|
||||
} else {
|
||||
$notAccessible++;
|
||||
echo "❌ ID: $notesid - $title\n";
|
||||
echo " S3 Key: $s3Key\n";
|
||||
$errors[] = [
|
||||
'id' => $notesid,
|
||||
'title' => $title,
|
||||
's3_key' => $s3Key,
|
||||
'reason' => 'File does not exist in S3'
|
||||
];
|
||||
}
|
||||
} catch (\Aws\Exception\AwsException $e) {
|
||||
$notAccessible++;
|
||||
echo "❌ ID: $notesid - $title\n";
|
||||
echo " Ошибка: " . $e->getMessage() . "\n";
|
||||
echo " S3 Key: $s3Key\n";
|
||||
$errors[] = [
|
||||
'id' => $notesid,
|
||||
'title' => $title,
|
||||
's3_key' => $s3Key,
|
||||
'reason' => 'AWS Exception: ' . $e->getMessage()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$notAccessible++;
|
||||
echo "❌ ID: $notesid - $title\n";
|
||||
echo " Ошибка: " . $e->getMessage() . "\n";
|
||||
$errors[] = [
|
||||
'id' => $notesid,
|
||||
'title' => $title,
|
||||
's3_key' => $s3Key,
|
||||
'reason' => 'Exception: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat("=", 80) . "\n";
|
||||
echo "СТАТИСТИКА:\n";
|
||||
echo " Доступных файлов: $accessible\n";
|
||||
echo " Недоступных файлов: $notAccessible\n";
|
||||
|
||||
if (!empty($errors)) {
|
||||
echo "\nНЕДОСТУПНЫЕ ФАЙЛЫ:\n";
|
||||
foreach ($errors as $error) {
|
||||
echo " - ID {$error['id']}: {$error['title']}\n";
|
||||
echo " S3 Key: {$error['s3_key']}\n";
|
||||
echo " Причина: {$error['reason']}\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
191
cleanup_disk.php
Normal file
191
cleanup_disk.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* Скрипт для безопасной очистки диска
|
||||
*
|
||||
* Удаляет:
|
||||
* 1. Старые бэкапы SQL (оставляет последние N)
|
||||
* 2. Большие логи (очищает или удаляет старые)
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
$dryRun = !isset($argv[1]) || $argv[1] !== '--execute';
|
||||
$keepBackups = isset($argv[2]) ? (int)$argv[2] : (isset($argv[1]) && is_numeric($argv[1]) ? (int)$argv[1] : 5); // Сколько бэкапов оставить
|
||||
|
||||
echo "Очистка диска\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
if (!$dryRun) {
|
||||
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run)\n";
|
||||
echo " Для реального удаления запустите: php cleanup_disk.php --execute [количество_бэкапов]\n";
|
||||
echo " Пример: php cleanup_disk.php --execute 5\n\n";
|
||||
} else {
|
||||
echo "⚠️ РЕЖИМ УДАЛЕНИЯ - файлы будут удалены!\n\n";
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'backups_found' => 0,
|
||||
'backups_to_delete' => 0,
|
||||
'backups_size' => 0,
|
||||
'logs_found' => 0,
|
||||
'logs_to_clean' => 0,
|
||||
'logs_size' => 0,
|
||||
'total_freed' => 0
|
||||
];
|
||||
|
||||
// 1. Обработка бэкапов SQL
|
||||
echo "1. ОБРАБОТКА БЭКАПОВ SQL\n";
|
||||
echo str_repeat("-", 80) . "\n";
|
||||
|
||||
$backupDir = __DIR__;
|
||||
$backups = glob($backupDir . '/backup_before_migration_*.sql');
|
||||
|
||||
if (empty($backups)) {
|
||||
echo " Бэкапы не найдены\n\n";
|
||||
} else {
|
||||
$stats['backups_found'] = count($backups);
|
||||
|
||||
// Сортируем по дате изменения (новые первыми)
|
||||
usort($backups, function($a, $b) {
|
||||
return filemtime($b) - filemtime($a);
|
||||
});
|
||||
|
||||
echo " Найдено бэкапов: {$stats['backups_found']}\n";
|
||||
echo " Оставим последних: {$keepBackups}\n";
|
||||
echo " Будет удалено: " . max(0, $stats['backups_found'] - $keepBackups) . "\n\n";
|
||||
|
||||
$toDelete = array_slice($backups, $keepBackups);
|
||||
$stats['backups_to_delete'] = count($toDelete);
|
||||
|
||||
if (!empty($toDelete)) {
|
||||
echo " Файлы для удаления:\n";
|
||||
foreach ($toDelete as $backup) {
|
||||
$size = filesize($backup);
|
||||
$stats['backups_size'] += $size;
|
||||
$sizeMB = round($size / 1024 / 1024, 2);
|
||||
$date = date('Y-m-d H:i:s', filemtime($backup));
|
||||
echo " - " . basename($backup) . " ({$sizeMB}MB, {$date})\n";
|
||||
}
|
||||
|
||||
if ($dryRun && !empty($toDelete)) {
|
||||
echo "\n Удаление файлов...\n";
|
||||
foreach ($toDelete as $backup) {
|
||||
if (unlink($backup)) {
|
||||
echo " ✅ " . basename($backup) . " - удален\n";
|
||||
} else {
|
||||
echo " ❌ " . basename($backup) . " - ошибка удаления\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
echo " Нет файлов для удаления\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Обработка больших логов
|
||||
echo "2. ОБРАБОТКА ЛОГОВ\n";
|
||||
echo str_repeat("-", 80) . "\n";
|
||||
|
||||
$largeLogs = [
|
||||
__DIR__ . '/wdall.log',
|
||||
__DIR__ . '/wdall2.log',
|
||||
__DIR__ . '/wa_inbound.log',
|
||||
__DIR__ . '/wa_outbound.log',
|
||||
];
|
||||
|
||||
foreach ($largeLogs as $logFile) {
|
||||
if (file_exists($logFile)) {
|
||||
$size = filesize($logFile);
|
||||
$sizeMB = round($size / 1024 / 1024, 2);
|
||||
|
||||
if ($size > 10 * 1024 * 1024) { // Больше 10MB
|
||||
$stats['logs_found']++;
|
||||
$stats['logs_size'] += $size;
|
||||
|
||||
echo " Найден большой лог: " . basename($logFile) . " ({$sizeMB}MB)\n";
|
||||
|
||||
if ($dryRun) {
|
||||
// Очищаем лог (оставляем последние 1000 строк)
|
||||
$lines = file($logFile);
|
||||
if (count($lines) > 1000) {
|
||||
$keepLines = array_slice($lines, -1000);
|
||||
if (file_put_contents($logFile, implode('', $keepLines))) {
|
||||
$newSize = filesize($logFile);
|
||||
$freedMB = round(($size - $newSize) / 1024 / 1024, 2);
|
||||
echo " ✅ Очищен (освобождено {$freedMB}MB)\n";
|
||||
$stats['logs_to_clean']++;
|
||||
} else {
|
||||
echo " ❌ Ошибка очистки\n";
|
||||
}
|
||||
} else {
|
||||
echo " ℹ️ Лог небольшой, пропущен\n";
|
||||
}
|
||||
} else {
|
||||
echo " ⏸️ Будет очищен (dry-run)\n";
|
||||
$stats['logs_to_clean']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка логов в папке logs/
|
||||
$logsDir = __DIR__ . '/logs';
|
||||
if (is_dir($logsDir)) {
|
||||
$logFiles = glob($logsDir . '/*.log*');
|
||||
foreach ($logFiles as $logFile) {
|
||||
$size = filesize($logFile);
|
||||
if ($size > 20 * 1024 * 1024) { // Больше 20MB
|
||||
$sizeMB = round($size / 1024 / 1024, 2);
|
||||
$mtime = filemtime($logFile);
|
||||
$daysOld = (time() - $mtime) / 86400;
|
||||
|
||||
if ($daysOld > 7) {
|
||||
$stats['logs_found']++;
|
||||
$stats['logs_size'] += $size;
|
||||
|
||||
echo " Старый большой лог: " . basename($logFile) . " ({$sizeMB}MB, " . round($daysOld) . " дней)\n";
|
||||
|
||||
if ($dryRun) {
|
||||
if (unlink($logFile)) {
|
||||
echo " ✅ Удален\n";
|
||||
$stats['logs_to_clean']++;
|
||||
} else {
|
||||
echo " ❌ Ошибка удаления\n";
|
||||
}
|
||||
} else {
|
||||
echo " ⏸️ Будет удален (dry-run)\n";
|
||||
$stats['logs_to_clean']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Итоговая статистика
|
||||
$stats['total_freed'] = $stats['backups_size'] + $stats['logs_size'];
|
||||
$totalFreedMB = round($stats['total_freed'] / 1024 / 1024, 2);
|
||||
$totalFreedGB = round($stats['total_freed'] / 1024 / 1024 / 1024, 2);
|
||||
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГОВАЯ СТАТИСТИКА:\n\n";
|
||||
echo "Бэкапы:\n";
|
||||
echo " - Найдено: {$stats['backups_found']}\n";
|
||||
echo " - Будет удалено: {$stats['backups_to_delete']}\n";
|
||||
echo " - Размер: " . round($stats['backups_size'] / 1024 / 1024 / 1024, 2) . "GB\n\n";
|
||||
echo "Логи:\n";
|
||||
echo " - Найдено больших: {$stats['logs_found']}\n";
|
||||
echo " - Будет обработано: {$stats['logs_to_clean']}\n";
|
||||
echo " - Размер: " . round($stats['logs_size'] / 1024 / 1024, 2) . "MB\n\n";
|
||||
echo "ОБЩЕЕ ОСВОБОЖДЕНИЕ: {$totalFreedGB}GB ({$totalFreedMB}MB)\n\n";
|
||||
|
||||
if (!$dryRun) {
|
||||
echo "⚠️ Это был режим проверки. Для реального удаления запустите:\n";
|
||||
echo " php cleanup_disk.php --execute {$keepBackups}\n\n";
|
||||
} else {
|
||||
echo "✅ Очистка завершена!\n\n";
|
||||
}
|
||||
|
||||
36
cleanup_nextcloud_logs.sh
Executable file
36
cleanup_nextcloud_logs.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для очистки логов Nextcloud
|
||||
|
||||
echo "=== ОЧИСТКА ЛОГОВ NEXTCLOUD ==="
|
||||
echo ""
|
||||
|
||||
# Проверяем размеры логов
|
||||
echo "Размеры логов до очистки:"
|
||||
docker exec nextcloud-fresh find /var/www/html/data -name "*.log" -type f -exec ls -lh {} \; 2>&1 | awk '{print $5, $9}'
|
||||
|
||||
echo ""
|
||||
echo "Очистка логов..."
|
||||
|
||||
# Очищаем nextcloud.log если больше 100MB
|
||||
SIZE=$(docker exec nextcloud-fresh stat -c%s /var/www/html/data/nextcloud.log 2>/dev/null || echo "0")
|
||||
if [ "$SIZE" -gt 104857600 ]; then
|
||||
echo "nextcloud.log больше 100MB, очищаем..."
|
||||
docker exec nextcloud-fresh truncate -s 0 /var/www/html/data/nextcloud.log
|
||||
echo "✅ nextcloud.log очищен"
|
||||
fi
|
||||
|
||||
# Очищаем flow.log если больше 50MB
|
||||
SIZE=$(docker exec nextcloud-fresh stat -c%s /var/www/html/data/flow.log 2>/dev/null || echo "0")
|
||||
if [ "$SIZE" -gt 52428800 ]; then
|
||||
echo "flow.log больше 50MB, очищаем..."
|
||||
docker exec nextcloud-fresh truncate -s 0 /var/www/html/data/flow.log
|
||||
echo "✅ flow.log очищен"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Размеры логов после очистки:"
|
||||
docker exec nextcloud-fresh find /var/www/html/data -name "*.log" -type f -exec ls -lh {} \; 2>&1 | awk '{print $5, $9}'
|
||||
|
||||
echo ""
|
||||
echo "✅ Очистка завершена"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"tecnickcom/tcpdf": "^6.7",
|
||||
"aws/aws-sdk-php": "^3.337",
|
||||
"predis/predis": "^3.2"
|
||||
"predis/predis": "^3.2",
|
||||
"phpoffice/phpword": "^1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,25 @@ class S3Client {
|
||||
|
||||
/**
|
||||
* Создание временной ссылки для скачивания
|
||||
* @param string $s3Key S3 ключ файла
|
||||
* @param mixed $expiresIn Время жизни URL в секундах (число) или строка типа '+10 minutes'
|
||||
*/
|
||||
public function getPresignedUrl($s3Key, $expiresIn = 3600) {
|
||||
try {
|
||||
// Преобразуем строку TTL в секунды, если нужно
|
||||
if (is_string($expiresIn)) {
|
||||
// Если строка начинается с '+', используем её как есть для strtotime
|
||||
if (strpos($expiresIn, '+') === 0) {
|
||||
$expiresIn = strtotime($expiresIn) - time();
|
||||
} else {
|
||||
// Иначе пытаемся распарсить как число секунд
|
||||
$expiresIn = (int)$expiresIn;
|
||||
}
|
||||
}
|
||||
|
||||
// Минимум 60 секунд, максимум 7 дней
|
||||
$expiresIn = max(60, min($expiresIn, 604800));
|
||||
|
||||
$cmd = $this->client->getCommand('GetObject', [
|
||||
'Bucket' => $this->bucket,
|
||||
'Key' => $s3Key
|
||||
@@ -97,7 +113,9 @@ class S3Client {
|
||||
} catch (AwsException $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
'error_code' => $e->getAwsErrorCode(),
|
||||
'request_id' => $e->getAwsRequestId()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
570
crm_extensions/file_storage/api/create_document_with_text.php
Normal file
570
crm_extensions/file_storage/api/create_document_with_text.php
Normal file
@@ -0,0 +1,570 @@
|
||||
<?php
|
||||
/**
|
||||
* Создание документа с текстом от AI
|
||||
*
|
||||
* Упрощенная версия - создает пустой DOCX, записывает в него текст от AI
|
||||
* и возвращает ссылку на редактирование
|
||||
*/
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/shared/EnvLoader.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
EnvLoader::load('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/.env');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// Параметры - поддерживаем JSON POST и обычные POST/GET
|
||||
$input = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (strpos($contentType, 'application/json') !== false) {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
} else {
|
||||
$input = $_POST;
|
||||
}
|
||||
}
|
||||
|
||||
$module = $input['module'] ?? $_GET['module'] ?? '';
|
||||
$recordId = $input['recordId'] ?? $_GET['recordId'] ?? '';
|
||||
$recordName = $input['recordName'] ?? $_GET['recordName'] ?? '';
|
||||
$fileName = $input['fileName'] ?? $_GET['fileName'] ?? '';
|
||||
$documentText = $input['documentText'] ?? $_GET['documentText'] ?? '';
|
||||
$documentType = $input['documentType'] ?? $_GET['documentType'] ?? 'docx'; // docx, xlsx, pptx
|
||||
|
||||
if (empty($module) || empty($recordId) || empty($fileName) || empty($documentText)) {
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Не указаны обязательные параметры: module, recordId, fileName, documentText'
|
||||
]));
|
||||
}
|
||||
|
||||
// Определяем папку модуля
|
||||
$moduleFolders = [
|
||||
'Project' => 'Project',
|
||||
'Contacts' => 'Contacts',
|
||||
'Accounts' => 'Accounts',
|
||||
'Invoice' => 'Invoice',
|
||||
'Quotes' => 'Quotes',
|
||||
'SalesOrder' => 'SalesOrder',
|
||||
'PurchaseOrder' => 'PurchaseOrder',
|
||||
'HelpDesk' => 'HelpDesk',
|
||||
'Leads' => 'Leads',
|
||||
'Potentials' => 'Potentials'
|
||||
];
|
||||
|
||||
$moduleFolder = $moduleFolders[$module] ?? 'Other';
|
||||
|
||||
// Формируем имя папки записи (заменяем пробелы и спецсимволы на подчеркивания)
|
||||
$recordName = preg_replace('/[\/\\\\:\*\?"<>\|\s]+/', '_', $recordName); // \s+ заменяет все пробелы на одно подчеркивание
|
||||
$recordName = trim($recordName, '_'); // Убираем подчеркивания в начале и конце
|
||||
$folderName = $recordName . '_' . $recordId;
|
||||
|
||||
// Путь к готовому документу (правильный формат: crm2/CRM_Active_Files/... без /crm/ в начале)
|
||||
$fileExtension = $documentType === 'xlsx' ? 'xlsx' : ($documentType === 'pptx' ? 'pptx' : 'docx');
|
||||
$ncPath = "crm2/CRM_Active_Files/Documents/{$moduleFolder}/{$folderName}/{$fileName}.{$fileExtension}";
|
||||
|
||||
error_log("=== CREATE DOCUMENT WITH TEXT ===");
|
||||
error_log("Module: {$module}, RecordId: {$recordId}");
|
||||
error_log("FileName: {$fileName}");
|
||||
error_log("DocumentType: {$documentType}");
|
||||
error_log("Text length: " . strlen($documentText));
|
||||
|
||||
// СОЗДАЕМ ДОКУМЕНТ С ТЕКСТОМ
|
||||
try {
|
||||
$documentContent = createDocumentWithText($documentText, $documentType);
|
||||
|
||||
if (empty($documentContent)) {
|
||||
throw new Exception('Не удалось создать документ');
|
||||
}
|
||||
|
||||
error_log("✅ Document created (" . strlen($documentContent) . " bytes)");
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Failed to create document: " . $e->getMessage());
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Ошибка создания документа: ' . $e->getMessage()
|
||||
]));
|
||||
}
|
||||
|
||||
// СОХРАНЯЕМ В S3
|
||||
$s3Path = $ncPath; // Путь уже без лишних слешей
|
||||
$s3Client = new Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => 'ru-1',
|
||||
'endpoint' => 'https://s3.twcstorage.ru',
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => EnvLoader::getRequired('S3_ACCESS_KEY'),
|
||||
'secret' => EnvLoader::getRequired('S3_SECRET_KEY')
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
$bucket = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
|
||||
try {
|
||||
$result = $s3Client->putObject([
|
||||
'Bucket' => $bucket,
|
||||
'Key' => $s3Path,
|
||||
'Body' => $documentContent,
|
||||
'ContentType' => getContentType($fileExtension)
|
||||
]);
|
||||
|
||||
error_log("✅ File saved to S3: {$s3Path}");
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Failed to save to S3: " . $e->getMessage());
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Ошибка сохранения: ' . $e->getMessage()
|
||||
]));
|
||||
}
|
||||
|
||||
// ПУБЛИКУЕМ СОБЫТИЕ В REDIS
|
||||
try {
|
||||
$redis = new Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => 'crm.clientright.ru',
|
||||
'port' => 6379,
|
||||
'password' => 'CRM_Redis_Pass_2025_Secure!'
|
||||
]);
|
||||
|
||||
$event = json_encode([
|
||||
'type' => 'file_created',
|
||||
'source' => 'ai_document_generator',
|
||||
'path' => $s3Path,
|
||||
'timestamp' => time()
|
||||
]);
|
||||
|
||||
$redis->publish('crm:file:events', $event);
|
||||
error_log("✅ Published event to Redis");
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Redis publish failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// ФОРМИРУЕМ ССЫЛКУ НА РЕДАКТИРОВАНИЕ
|
||||
$s3Url = 'https://s3.twcstorage.ru/' . $bucket . '/' . $s3Path;
|
||||
$editUrl = 'https://crm.clientright.ru/crm_extensions/file_storage/api/open_file_v2.php?recordId=' . urlencode($recordId) . '&fileName=' . urlencode($s3Url);
|
||||
|
||||
// ВОЗВРАЩАЕМ РЕЗУЛЬТАТ
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Документ создан успешно',
|
||||
'documentName' => $fileName . '.' . $fileExtension,
|
||||
'documentUrl' => $s3Url,
|
||||
'editUrl' => $editUrl,
|
||||
'path' => $s3Path
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
/**
|
||||
* Создает документ с текстом
|
||||
*/
|
||||
function createDocumentWithText($text, $type) {
|
||||
if ($type === 'docx') {
|
||||
return createDocxWithText($text);
|
||||
} elseif ($type === 'xlsx') {
|
||||
return createXlsxWithText($text);
|
||||
} elseif ($type === 'pptx') {
|
||||
return createPptxWithText($text);
|
||||
} else {
|
||||
throw new Exception("Неподдерживаемый тип документа: {$type}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает DOCX с текстом и форматированием (поддержка Markdown)
|
||||
*/
|
||||
function createDocxWithText($text) {
|
||||
// Проверяем наличие PHPWord
|
||||
if (class_exists('\PhpOffice\PhpWord\PhpWord')) {
|
||||
// Используем PHPWord если доступен
|
||||
$phpWord = new \PhpOffice\PhpWord\PhpWord();
|
||||
|
||||
// Настройки документа
|
||||
$phpWord->setDefaultFontName('Times New Roman');
|
||||
$phpWord->setDefaultFontSize(12);
|
||||
|
||||
// Добавляем секцию
|
||||
$section = $phpWord->addSection([
|
||||
'marginTop' => 1134, // 2 см
|
||||
'marginRight' => 1134, // 2 см
|
||||
'marginBottom' => 1134, // 2 см
|
||||
'marginLeft' => 1701 // 3 см
|
||||
]);
|
||||
|
||||
// Парсим Markdown и создаем форматированный документ
|
||||
parseMarkdownToPHPWord($section, $text);
|
||||
|
||||
// Сохраняем во временный файл
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'docx_') . '.docx';
|
||||
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
|
||||
$writer->save($tempFile);
|
||||
|
||||
// Читаем содержимое
|
||||
$content = file_get_contents($tempFile);
|
||||
|
||||
// Удаляем временный файл
|
||||
unlink($tempFile);
|
||||
|
||||
return $content;
|
||||
} else {
|
||||
// Fallback: создаем простой DOCX через ZIP (DOCX это ZIP архив)
|
||||
return createSimpleDocx($text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит Markdown и добавляет форматированный текст в PHPWord секцию
|
||||
*
|
||||
* Поддерживает:
|
||||
* - Заголовки: # H1, ## H2, ### H3
|
||||
* - Жирный: **текст** или __текст__
|
||||
* - Курсив: *текст* или _текст_
|
||||
* - Маркированные списки: - или *
|
||||
* - Нумерованные списки: 1. 2. 3.
|
||||
* - Выделение: `код`
|
||||
*/
|
||||
function parseMarkdownToPHPWord($section, $text) {
|
||||
$lines = explode("\n", $text);
|
||||
$inList = false;
|
||||
$listType = null; // 'ul' или 'ol'
|
||||
$listItems = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = rtrim($line);
|
||||
|
||||
// Пустая строка
|
||||
if (empty($line)) {
|
||||
if ($inList) {
|
||||
// Завершаем список
|
||||
addListToSection($section, $listItems, $listType);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
$listType = null;
|
||||
}
|
||||
$section->addText('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Заголовки
|
||||
if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $matches)) {
|
||||
if ($inList) {
|
||||
addListToSection($section, $listItems, $listType);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
$listType = null;
|
||||
}
|
||||
|
||||
$level = strlen($matches[1]);
|
||||
$title = trim($matches[2]);
|
||||
$fontSize = [1 => 18, 2 => 16, 3 => 14][$level] ?? 14;
|
||||
|
||||
$section->addText($title, [
|
||||
'name' => 'Times New Roman',
|
||||
'size' => $fontSize,
|
||||
'bold' => true,
|
||||
'color' => '000000'
|
||||
], [
|
||||
'spaceAfter' => 240,
|
||||
'spaceBefore' => $level === 1 ? 480 : 240
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Маркированный список
|
||||
if (preg_match('/^[-*]\s+(.+)$/', $line, $matches)) {
|
||||
if (!$inList || $listType !== 'ul') {
|
||||
if ($inList && $listType === 'ol') {
|
||||
addListToSection($section, $listItems, $listType);
|
||||
$listItems = [];
|
||||
}
|
||||
$inList = true;
|
||||
$listType = 'ul';
|
||||
}
|
||||
$listItems[] = trim($matches[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Нумерованный список
|
||||
if (preg_match('/^\d+\.\s+(.+)$/', $line, $matches)) {
|
||||
if (!$inList || $listType !== 'ol') {
|
||||
if ($inList && $listType === 'ul') {
|
||||
addListToSection($section, $listItems, $listType);
|
||||
$listItems = [];
|
||||
}
|
||||
$inList = true;
|
||||
$listType = 'ol';
|
||||
}
|
||||
$listItems[] = trim($matches[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обычный текст
|
||||
if ($inList) {
|
||||
addListToSection($section, $listItems, $listType);
|
||||
$listItems = [];
|
||||
$inList = false;
|
||||
$listType = null;
|
||||
}
|
||||
|
||||
// Парсим форматирование в строке (жирный, курсив, код)
|
||||
addFormattedText($section, $line);
|
||||
}
|
||||
|
||||
// Если список не завершен
|
||||
if ($inList && !empty($listItems)) {
|
||||
addListToSection($section, $listItems, $listType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет форматированный текст в секцию
|
||||
*/
|
||||
function addFormattedText($section, $text) {
|
||||
// Парсим inline форматирование: **жирный**, *курсив*, `код`
|
||||
$textRun = $section->addTextRun(['spaceAfter' => 240]);
|
||||
|
||||
// Разбиваем текст на части с форматированием
|
||||
$parts = preg_split('/(\*\*.*?\*\*|__.*?__|\*.*?\*|_.*?_|`.*?`)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (empty($part)) continue;
|
||||
|
||||
// Жирный **текст** или __текст__
|
||||
if (preg_match('/^\*\*(.+?)\*\*$/', $part, $m) || preg_match('/^__(.+?)__$/', $part, $m)) {
|
||||
$textRun->addText($m[1], [
|
||||
'name' => 'Times New Roman',
|
||||
'size' => 12,
|
||||
'bold' => true
|
||||
]);
|
||||
}
|
||||
// Курсив *текст* или _текст_ (но не жирный)
|
||||
elseif (preg_match('/^\*(.+?)\*$/', $part, $m) || (preg_match('/^_(.+?)_$/', $part, $m) && !preg_match('/^__/', $part))) {
|
||||
$textRun->addText($m[1], [
|
||||
'name' => 'Times New Roman',
|
||||
'size' => 12,
|
||||
'italic' => true
|
||||
]);
|
||||
}
|
||||
// Код `текст`
|
||||
elseif (preg_match('/^`(.+?)`$/', $part, $m)) {
|
||||
$textRun->addText($m[1], [
|
||||
'name' => 'Courier New',
|
||||
'size' => 11,
|
||||
'color' => '0066CC'
|
||||
]);
|
||||
}
|
||||
// Обычный текст
|
||||
else {
|
||||
$textRun->addText($part, [
|
||||
'name' => 'Times New Roman',
|
||||
'size' => 12
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет список в секцию
|
||||
*/
|
||||
function addListToSection($section, $items, $type) {
|
||||
if (empty($items)) return;
|
||||
|
||||
$fontStyle = [
|
||||
'name' => 'Times New Roman',
|
||||
'size' => 12
|
||||
];
|
||||
|
||||
$paragraphStyle = [
|
||||
'spaceAfter' => 120,
|
||||
'indentation' => ['left' => 720] // 0.5 дюйма
|
||||
];
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$prefix = $type === 'ol' ? ($index + 1) . '. ' : '• ';
|
||||
|
||||
// Парсим форматирование в элементе списка
|
||||
$textRun = $section->addTextRun($paragraphStyle);
|
||||
$textRun->addText($prefix, array_merge($fontStyle, ['bold' => true]));
|
||||
|
||||
// Добавляем текст элемента с форматированием
|
||||
$formattedParts = preg_split('/(\*\*.*?\*\*|__.*?__|\*.*?\*|_.*?_|`.*?`)/', $item, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
foreach ($formattedParts as $part) {
|
||||
if (empty($part)) continue;
|
||||
|
||||
if (preg_match('/^\*\*(.+?)\*\*$/', $part, $m) || preg_match('/^__(.+?)__$/', $part, $m)) {
|
||||
$textRun->addText($m[1], array_merge($fontStyle, ['bold' => true]));
|
||||
} elseif (preg_match('/^\*(.+?)\*$/', $part, $m) || (preg_match('/^_(.+?)_$/', $part, $m) && !preg_match('/^__/', $part))) {
|
||||
$textRun->addText($m[1], array_merge($fontStyle, ['italic' => true]));
|
||||
} elseif (preg_match('/^`(.+?)`$/', $part, $m)) {
|
||||
$textRun->addText($m[1], array_merge($fontStyle, ['name' => 'Courier New', 'color' => '0066CC']));
|
||||
} else {
|
||||
$textRun->addText($part, $fontStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустая строка после списка
|
||||
$section->addText('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает простой DOCX без PHPWord (через ZIP архив)
|
||||
*/
|
||||
function createSimpleDocx($text) {
|
||||
// Экранируем XML спецсимволы
|
||||
$text = htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Разбиваем на параграфы
|
||||
$paragraphs = explode("\n", $text);
|
||||
$paragraphXml = '';
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
if (empty($paragraph)) {
|
||||
$paragraphXml .= '<w:p><w:r><w:t></w:t></w:r></w:p>';
|
||||
} else {
|
||||
$paragraphXml .= '<w:p><w:r><w:t>' . $paragraph . '</w:t></w:r></w:p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Минимальный DOCX структура (ZIP архив с XML файлами)
|
||||
$tempDir = sys_get_temp_dir() . '/docx_' . uniqid();
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
// [Content_Types].xml
|
||||
file_put_contents($tempDir . '/[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>');
|
||||
|
||||
// _rels/.rels
|
||||
mkdir($tempDir . '/_rels', 0755, true);
|
||||
file_put_contents($tempDir . '/_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>');
|
||||
|
||||
// word/document.xml
|
||||
mkdir($tempDir . '/word', 0755, true);
|
||||
file_put_contents($tempDir . '/word/document.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>' . $paragraphXml . '</w:body>
|
||||
</w:document>');
|
||||
|
||||
// Создаем ZIP архив
|
||||
$zipFile = tempnam(sys_get_temp_dir(), 'docx_') . '.docx';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) {
|
||||
$zip->addFile($tempDir . '/[Content_Types].xml', '[Content_Types].xml');
|
||||
$zip->addFile($tempDir . '/_rels/.rels', '_rels/.rels');
|
||||
$zip->addFile($tempDir . '/word/document.xml', 'word/document.xml');
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
// Читаем содержимое
|
||||
$content = file_get_contents($zipFile);
|
||||
|
||||
// Удаляем временные файлы
|
||||
unlink($zipFile);
|
||||
array_map('unlink', glob($tempDir . '/*/*'));
|
||||
array_map('unlink', glob($tempDir . '/*'));
|
||||
rmdir($tempDir . '/word');
|
||||
rmdir($tempDir . '/_rels');
|
||||
rmdir($tempDir);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает XLSX с текстом (в первой ячейке)
|
||||
*/
|
||||
function createXlsxWithText($text) {
|
||||
if (class_exists('\PhpOffice\PhpSpreadsheet\Spreadsheet')) {
|
||||
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// Записываем текст в первую ячейку
|
||||
$sheet->setCellValue('A1', $text);
|
||||
|
||||
// Автоподбор ширины колонки
|
||||
$sheet->getColumnDimension('A')->setAutoSize(true);
|
||||
|
||||
// Сохраняем во временный файл
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'xlsx_') . '.xlsx';
|
||||
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
||||
$writer->save($tempFile);
|
||||
|
||||
// Читаем содержимое
|
||||
$content = file_get_contents($tempFile);
|
||||
|
||||
// Удаляем временный файл
|
||||
unlink($tempFile);
|
||||
|
||||
return $content;
|
||||
} else {
|
||||
throw new Exception('PhpSpreadsheet не установлен. Установите через composer: composer require phpoffice/phpspreadsheet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает PPTX с текстом (на первом слайде)
|
||||
*/
|
||||
function createPptxWithText($text) {
|
||||
if (class_exists('\PhpOffice\PhpPresentation\PhpPresentation')) {
|
||||
$presentation = new \PhpOffice\PhpPresentation\PhpPresentation();
|
||||
$slide = $presentation->getActiveSlide();
|
||||
|
||||
// Создаем текстовую фигуру
|
||||
$shape = $slide->createRichTextShape()
|
||||
->setHeight(400)
|
||||
->setWidth(800)
|
||||
->setOffsetX(100)
|
||||
->setOffsetY(100);
|
||||
|
||||
// Разбиваем текст на параграфы
|
||||
$paragraphs = explode("\n", $text);
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
if (!empty($paragraph)) {
|
||||
$shape->createTextRun($paragraph);
|
||||
$shape->createParagraph();
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем во временный файл
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'pptx_') . '.pptx';
|
||||
$writer = \PhpOffice\PhpPresentation\IOFactory::createWriter($presentation, 'PowerPoint2007');
|
||||
$writer->save($tempFile);
|
||||
|
||||
// Читаем содержимое
|
||||
$content = file_get_contents($tempFile);
|
||||
|
||||
// Удаляем временный файл
|
||||
unlink($tempFile);
|
||||
|
||||
return $content;
|
||||
} else {
|
||||
throw new Exception('PhpPresentation не установлен. Установите через composer: composer require phpoffice/phppresentation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет Content-Type для файла
|
||||
*/
|
||||
function getContentType($fileType) {
|
||||
$types = [
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
];
|
||||
return $types[$fileType] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
251
crm_extensions/file_storage/api/create_from_template.php
Normal file
251
crm_extensions/file_storage/api/create_from_template.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
/**
|
||||
* Создание документа из шаблона Nextcloud
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Получаем шаблон из Nextcloud через WebDAV
|
||||
* 2. Заполняем переменные через PHPWord
|
||||
* 3. Сохраняем готовый документ в папку проекта
|
||||
* 4. Открываем в OnlyOffice
|
||||
*/
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/shared/EnvLoader.php';
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
|
||||
EnvLoader::load('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/.env');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
// Параметры
|
||||
$module = $_GET['module'] ?? '';
|
||||
$recordId = $_GET['recordId'] ?? '';
|
||||
$recordName = $_GET['recordName'] ?? '';
|
||||
$fileName = $_GET['fileName'] ?? '';
|
||||
$templateName = $_GET['templateName'] ?? ''; // Имя шаблона (например, "pretenziya.docx")
|
||||
$variables = json_decode($_GET['variables'] ?? '{}', true); // Переменные для заполнения
|
||||
|
||||
if (empty($module) || empty($recordId) || empty($fileName) || empty($templateName)) {
|
||||
die(json_encode(['success' => false, 'error' => 'Не указаны обязательные параметры']));
|
||||
}
|
||||
|
||||
// Nextcloud credentials
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$username = 'admin';
|
||||
$password = 'office';
|
||||
|
||||
// Определяем папку модуля
|
||||
$moduleFolders = [
|
||||
'Project' => 'Project',
|
||||
'Contacts' => 'Contacts',
|
||||
'Accounts' => 'Accounts',
|
||||
'Invoice' => 'Invoice',
|
||||
'Quotes' => 'Quotes',
|
||||
'SalesOrder' => 'SalesOrder',
|
||||
'PurchaseOrder' => 'PurchaseOrder',
|
||||
'HelpDesk' => 'HelpDesk',
|
||||
'Leads' => 'Leads',
|
||||
'Potentials' => 'Potentials'
|
||||
];
|
||||
|
||||
$moduleFolder = $moduleFolders[$module] ?? 'Other';
|
||||
|
||||
// Формируем имя папки записи
|
||||
$recordName = preg_replace('/[\/\\\\:\*\?"<>\|]/', '_', $recordName);
|
||||
$folderName = $recordName . '_' . $recordId;
|
||||
|
||||
// ONLYOFFICE хранит шаблоны в папке /Templates/ в корне пользователя
|
||||
// Путь к шаблону в Nextcloud
|
||||
$templatePath = "/Templates/{$templateName}";
|
||||
$templateWebDAVUrl = $nextcloudUrl . '/remote.php/dav/files/' . $username . $templatePath;
|
||||
|
||||
// Путь к готовому документу
|
||||
$fileType = pathinfo($templateName, PATHINFO_EXTENSION);
|
||||
$ncPath = "/crm/crm2/CRM_Active_Files/Documents/{$moduleFolder}/{$folderName}/{$fileName}.{$fileType}";
|
||||
|
||||
error_log("=== CREATE FROM TEMPLATE ===");
|
||||
error_log("Template: {$templateName}");
|
||||
error_log("Variables: " . json_encode($variables, JSON_UNESCAPED_UNICODE));
|
||||
error_log("Output path: {$ncPath}");
|
||||
|
||||
// 1. СКАЧИВАЕМ ШАБЛОН ИЗ NEXTCLOUD
|
||||
$ch = curl_init($templateWebDAVUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$templateContent = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($templateContent)) {
|
||||
die(json_encode(['success' => false, 'error' => "Шаблон не найден: {$templateName}"]));
|
||||
}
|
||||
|
||||
error_log("✅ Template downloaded (" . strlen($templateContent) . " bytes)");
|
||||
|
||||
// 2. ЗАПОЛНЯЕМ ПЕРЕМЕННЫЕ В ШАБЛОНЕ
|
||||
$filledContent = fillTemplateVariables($templateContent, $variables, $fileType);
|
||||
|
||||
// 3. СОХРАНЯЕМ В S3
|
||||
$s3Path = ltrim($ncPath, '/');
|
||||
$s3Client = new Aws\S3\S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => 'ru-1',
|
||||
'endpoint' => 'https://s3.twcstorage.ru',
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => EnvLoader::getRequired('S3_ACCESS_KEY'),
|
||||
'secret' => EnvLoader::getRequired('S3_SECRET_KEY')
|
||||
],
|
||||
'suppress_php_deprecation_warning' => true
|
||||
]);
|
||||
|
||||
$bucket = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
|
||||
try {
|
||||
$result = $s3Client->putObject([
|
||||
'Bucket' => $bucket,
|
||||
'Key' => $s3Path,
|
||||
'Body' => $filledContent,
|
||||
'ContentType' => getContentType($fileType)
|
||||
]);
|
||||
|
||||
error_log("✅ File saved to S3: {$s3Path}");
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Failed to save to S3: " . $e->getMessage());
|
||||
die(json_encode(['success' => false, 'error' => "Ошибка сохранения: " . $e->getMessage()]));
|
||||
}
|
||||
|
||||
// 4. ПУБЛИКУЕМ СОБЫТИЕ В REDIS
|
||||
try {
|
||||
$redis = new Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => 'crm.clientright.ru',
|
||||
'port' => 6379,
|
||||
'password' => 'CRM_Redis_Pass_2025_Secure!'
|
||||
]);
|
||||
|
||||
$event = json_encode([
|
||||
'type' => 'file_created',
|
||||
'source' => 'crm_template',
|
||||
'path' => $s3Path,
|
||||
'timestamp' => time()
|
||||
]);
|
||||
|
||||
$redis->publish('crm:file:events', $event);
|
||||
error_log("✅ Published event to Redis");
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Redis publish failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// 5. ОТКРЫВАЕМ В ONLYOFFICE
|
||||
$s3Url = 'https://s3.twcstorage.ru/' . $bucket . '/' . $s3Path;
|
||||
$redirectUrl = '/crm_extensions/file_storage/api/open_file_v2.php?recordId=' . urlencode($recordId) . '&fileName=' . urlencode($s3Url);
|
||||
|
||||
header('Location: ' . $redirectUrl);
|
||||
exit;
|
||||
|
||||
/**
|
||||
* Заполняет переменные в шаблоне
|
||||
*
|
||||
* Поддерживает два формата:
|
||||
* 1. Простая замена {VARIABLE_NAME} → значение
|
||||
* 2. PHPWord для сложных документов
|
||||
*/
|
||||
function fillTemplateVariables($content, $variables, $fileType) {
|
||||
if ($fileType === 'docx') {
|
||||
// Используем PHPWord для DOCX
|
||||
return fillDocxTemplate($content, $variables);
|
||||
} else {
|
||||
// Для других форматов - простая замена
|
||||
return fillSimpleTemplate($content, $variables);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполнение DOCX через PHPWord
|
||||
*/
|
||||
function fillDocxTemplate($content, $variables) {
|
||||
// Сохраняем во временный файл
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'template_') . '.docx';
|
||||
file_put_contents($tempFile, $content);
|
||||
|
||||
try {
|
||||
$phpWord = \PhpOffice\PhpWord\IOFactory::load($tempFile);
|
||||
|
||||
// Заменяем переменные во всех секциях
|
||||
foreach ($phpWord->getSections() as $section) {
|
||||
foreach ($section->getElements() as $element) {
|
||||
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
|
||||
$text = $element->getText();
|
||||
$text = replaceVariables($text, $variables);
|
||||
$element->setText($text);
|
||||
} elseif ($element instanceof \PhpOffice\PhpWord\Element\TextRun) {
|
||||
foreach ($element->getElements() as $textElement) {
|
||||
if ($textElement instanceof \PhpOffice\PhpWord\Element\Text) {
|
||||
$text = $textElement->getText();
|
||||
$text = replaceVariables($text, $variables);
|
||||
$textElement->setText($text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем результат
|
||||
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
|
||||
$outputFile = tempnam(sys_get_temp_dir(), 'output_') . '.docx';
|
||||
$writer->save($outputFile);
|
||||
|
||||
$result = file_get_contents($outputFile);
|
||||
|
||||
// Удаляем временные файлы
|
||||
unlink($tempFile);
|
||||
unlink($outputFile);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("PHPWord error: " . $e->getMessage());
|
||||
// Fallback на простую замену
|
||||
unlink($tempFile);
|
||||
return fillSimpleTemplate($content, $variables);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Простая замена переменных {VAR} → значение
|
||||
*/
|
||||
function fillSimpleTemplate($content, $variables) {
|
||||
foreach ($variables as $key => $value) {
|
||||
$content = str_replace('{' . strtoupper($key) . '}', $value, $content);
|
||||
$content = str_replace('{{' . strtoupper($key) . '}}', $value, $content);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальная замена переменных
|
||||
*/
|
||||
function replaceVariables($text, $variables) {
|
||||
foreach ($variables as $key => $value) {
|
||||
$text = str_replace('{' . strtoupper($key) . '}', $value, $text);
|
||||
$text = str_replace('{{' . strtoupper($key) . '}}', $value, $text);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет Content-Type для файла
|
||||
*/
|
||||
function getContentType($fileType) {
|
||||
$types = [
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
];
|
||||
return $types[$fileType] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
143
crm_extensions/file_storage/api/list_templates.php
Normal file
143
crm_extensions/file_storage/api/list_templates.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* Получение списка шаблонов из Nextcloud через WebDAV
|
||||
*
|
||||
* Использует WebDAV PROPFIND вместо несуществующего API endpoint
|
||||
*/
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/shared/EnvLoader.php';
|
||||
EnvLoader::load('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/.env');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$username = 'admin';
|
||||
$password = 'office';
|
||||
// ONLYOFFICE хранит шаблоны в папке /Templates/ в корне пользователя
|
||||
$templatesPath = '/Templates/';
|
||||
|
||||
$webdavUrl = $nextcloudUrl . '/remote.php/dav/files/' . $username . $templatesPath;
|
||||
|
||||
error_log("=== LIST TEMPLATES ===");
|
||||
error_log("WebDAV URL: {$webdavUrl}");
|
||||
|
||||
// PROPFIND запрос для получения списка файлов
|
||||
$propfindXml = '<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:getlastmodified/>
|
||||
</d:prop>
|
||||
</d:propfind>';
|
||||
|
||||
$ch = curl_init($webdavUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Depth: 1',
|
||||
'Content-Type: application/xml'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $propfindXml);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("HTTP Code: {$httpCode}");
|
||||
error_log("Response length: " . strlen($response));
|
||||
|
||||
if ($httpCode === 404) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Папка Templates не найдена. Создайте папку /crm/Templates/ в Nextcloud и загрузите туда шаблоны.',
|
||||
'templates' => []
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($httpCode !== 207) { // 207 Multi-Status для PROPFIND
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => "Ошибка WebDAV: HTTP {$httpCode}" . ($error ? " - {$error}" : ''),
|
||||
'templates' => []
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Парсим XML ответ
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = @simplexml_load_string($response);
|
||||
|
||||
if ($xml === false) {
|
||||
$errors = libxml_get_errors();
|
||||
libxml_clear_errors();
|
||||
error_log("XML Parse Error: " . print_r($errors, true));
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Ошибка парсинга XML ответа',
|
||||
'templates' => []
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Регистрируем namespace для XPath
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
|
||||
$templates = [];
|
||||
$basePath = rtrim($webdavUrl, '/');
|
||||
|
||||
foreach ($xml->xpath('//d:response') as $response) {
|
||||
$href = (string)$response->xpath('.//d:href')[0];
|
||||
$displayName = (string)$response->xpath('.//d:displayname')[0];
|
||||
$contentType = (string)($response->xpath('.//d:getcontenttype')[0] ?? '');
|
||||
$contentLength = (string)($response->xpath('.//d:getcontentlength')[0] ?? '0');
|
||||
$lastModified = (string)($response->xpath('.//d:getlastmodified')[0] ?? '');
|
||||
|
||||
// Пропускаем саму папку
|
||||
if (rtrim($href, '/') === $basePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Пропускаем подпапки (если есть)
|
||||
if (empty($contentType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Только Office файлы
|
||||
$isOfficeFile = (
|
||||
strpos($contentType, 'officedocument') !== false ||
|
||||
strpos($contentType, 'msword') !== false ||
|
||||
strpos($contentType, 'spreadsheet') !== false ||
|
||||
strpos($contentType, 'presentation') !== false ||
|
||||
strpos($contentType, 'opendocument') !== false
|
||||
);
|
||||
|
||||
if ($isOfficeFile) {
|
||||
// Извлекаем имя файла из пути
|
||||
$fileName = basename($href);
|
||||
|
||||
$templates[] = [
|
||||
'name' => $displayName ?: $fileName,
|
||||
'fileName' => $fileName,
|
||||
'path' => $href,
|
||||
'type' => $contentType,
|
||||
'size' => (int)$contentLength,
|
||||
'modified' => $lastModified
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
error_log("Found " . count($templates) . " templates");
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'templates' => $templates,
|
||||
'count' => count($templates)
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
@@ -1,97 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* Открытие файла через Nextcloud + OnlyOffice
|
||||
* Для сравнения с прямым OnlyOffice
|
||||
* Открытие файла через Nextcloud (РАБОЧАЯ ВЕРСИЯ v2)
|
||||
* Использует Redis индекс для быстрого получения FileID
|
||||
*/
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/shared/EnvLoader.php';
|
||||
EnvLoader::load('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/.env');
|
||||
|
||||
// Отключаем вывод ошибок в браузер
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
$fileName = isset($_GET['fileName']) ? $_GET['fileName'] : '';
|
||||
$recordId = isset($_GET['recordId']) ? $_GET['recordId'] : '';
|
||||
// Отключаем buffering
|
||||
if (ob_get_level()) ob_end_clean();
|
||||
|
||||
if (empty($fileName)) {
|
||||
die("❌ fileName не указан");
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$recordId = isset($_GET['recordId']) ? (int)$_GET['recordId'] : 0;
|
||||
|
||||
if ($recordId <= 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid recordId']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Извлекаем S3 путь
|
||||
$s3Path = '';
|
||||
if (strpos($fileName, 'http') === 0) {
|
||||
$fileName = urldecode($fileName);
|
||||
$bucketId = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
$pos = strpos($fileName, $bucketId . '/');
|
||||
if ($pos !== false) {
|
||||
$s3Path = substr($fileName, $pos + strlen($bucketId) + 1);
|
||||
try {
|
||||
// 1. Получаем filename из БД
|
||||
$db = new mysqli('localhost', 'ci20465_72new', 'EcY979Rn', 'ci20465_72new');
|
||||
|
||||
if ($db->connect_error) {
|
||||
throw new Exception('DB connection failed');
|
||||
}
|
||||
|
||||
$db->set_charset('utf8mb4');
|
||||
|
||||
$stmt = $db->prepare("SELECT filename FROM vtiger_notes WHERE notesid = ?");
|
||||
$stmt->bind_param('i', $recordId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$db->close();
|
||||
|
||||
if (!$row || empty($row['filename'])) {
|
||||
throw new Exception('File not found in DB');
|
||||
}
|
||||
|
||||
$fileName = $row['filename'];
|
||||
|
||||
// 2. Извлекаем S3 путь из URL
|
||||
$bucketId = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
$fileName = rawurldecode($fileName);
|
||||
$pos = strpos($fileName, $bucketId . '/');
|
||||
|
||||
if ($pos === false) {
|
||||
throw new Exception('Invalid S3 path in filename');
|
||||
}
|
||||
|
||||
$s3Path = substr($fileName, $pos + strlen($bucketId) + 1);
|
||||
|
||||
// 3. Получаем FileID из Redis
|
||||
$redis = new Redis();
|
||||
if (!$redis->connect('crm.clientright.ru', 6379)) {
|
||||
throw new Exception('Redis connection failed');
|
||||
}
|
||||
|
||||
$redis->auth('CRM_Redis_Pass_2025_Secure!');
|
||||
|
||||
$redisKey = "crm:nc:fileid:" . $s3Path;
|
||||
$cached = $redis->get($redisKey);
|
||||
|
||||
if (!$cached) {
|
||||
$redis->close();
|
||||
throw new Exception('FileID not found in Redis index. Key: ' . substr($redisKey, 0, 100));
|
||||
}
|
||||
|
||||
$data = json_decode($cached, true);
|
||||
$fileId = $data['fileId'] ?? null;
|
||||
|
||||
$redis->close();
|
||||
|
||||
if (!$fileId) {
|
||||
throw new Exception('Invalid FileID data in Redis');
|
||||
}
|
||||
|
||||
// 4. Формируем URL для Nextcloud
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$ncPath = '/crm/' . $s3Path;
|
||||
$dirPath = dirname($ncPath);
|
||||
|
||||
$redirectUrl = $nextcloudUrl . '/apps/files/files/' . $fileId . '?dir=' . urlencode($dirPath) . '&openfile=true';
|
||||
|
||||
// 5. Возвращаем успешный ответ
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'fileId' => $fileId,
|
||||
'redirectUrl' => $redirectUrl,
|
||||
'source' => 'redis',
|
||||
'recordId' => $recordId
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'recordId' => $recordId
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($s3Path)) {
|
||||
die("❌ Не удалось извлечь путь из URL");
|
||||
}
|
||||
|
||||
// Nextcloud credentials
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$username = 'admin';
|
||||
$password = 'office';
|
||||
|
||||
// Формируем WebDAV путь
|
||||
$ncPath = '/crm/' . $s3Path;
|
||||
$webdavUrl = $nextcloudUrl . '/remote.php/dav/files/' . $username . $ncPath;
|
||||
|
||||
error_log("=== NEXTCLOUD OPEN ===");
|
||||
error_log("S3 Path: " . $s3Path);
|
||||
error_log("Nextcloud WebDAV: " . $webdavUrl);
|
||||
|
||||
// Получаем fileId через PROPFIND
|
||||
$ch = curl_init($webdavUrl);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Depth: 0',
|
||||
'Content-Type: application/xml'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, '<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
</d:prop>
|
||||
</d:propfind>');
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("PROPFIND HTTP код: " . $httpCode);
|
||||
|
||||
if ($httpCode !== 207) {
|
||||
die("❌ Файл не найден в Nextcloud (HTTP $httpCode). Возможно, он не проиндексирован.");
|
||||
}
|
||||
|
||||
// Извлекаем fileId из XML
|
||||
preg_match('/<oc:fileid>(\d+)<\/oc:fileid>/', $response, $matches);
|
||||
if (!isset($matches[1])) {
|
||||
die("❌ Не удалось получить fileId");
|
||||
}
|
||||
|
||||
$fileId = $matches[1];
|
||||
error_log("Получен fileId: " . $fileId);
|
||||
|
||||
// Извлекаем директорию из пути
|
||||
$dirPath = dirname($ncPath);
|
||||
|
||||
// Формируем URL для открытия в Nextcloud
|
||||
// Nextcloud автоматически откроет OnlyOffice для редактирования
|
||||
$redirectUrl = $nextcloudUrl . '/apps/files/files/' . $fileId . '?dir=' . urlencode($dirPath) . '&openfile=true';
|
||||
|
||||
error_log("Redirect to: " . $redirectUrl);
|
||||
|
||||
// Редирект в Nextcloud
|
||||
header('Location: ' . $redirectUrl);
|
||||
exit;
|
||||
?>
|
||||
|
||||
|
||||
112
crm_extensions/file_storage/api/nextcloud_open_v2.php
Normal file
112
crm_extensions/file_storage/api/nextcloud_open_v2.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* Открытие файла через Nextcloud (РАБОЧАЯ ВЕРСИЯ v2)
|
||||
* Использует Redis индекс для быстрого получения FileID
|
||||
*/
|
||||
|
||||
// Отключаем вывод ошибок в браузер
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Отключаем buffering
|
||||
if (ob_get_level()) ob_end_clean();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$recordId = isset($_GET['recordId']) ? (int)$_GET['recordId'] : 0;
|
||||
|
||||
if ($recordId <= 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid recordId']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Получаем filename из БД
|
||||
$db = new mysqli('localhost', 'ci20465_72new', 'EcY979Rn', 'ci20465_72new');
|
||||
|
||||
if ($db->connect_error) {
|
||||
throw new Exception('DB connection failed');
|
||||
}
|
||||
|
||||
$db->set_charset('utf8mb4');
|
||||
|
||||
$stmt = $db->prepare("SELECT filename FROM vtiger_notes WHERE notesid = ?");
|
||||
$stmt->bind_param('i', $recordId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$db->close();
|
||||
|
||||
if (!$row || empty($row['filename'])) {
|
||||
throw new Exception('File not found in DB');
|
||||
}
|
||||
|
||||
$fileName = $row['filename'];
|
||||
|
||||
// 2. Извлекаем S3 путь из URL
|
||||
$bucketId = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
$fileName = rawurldecode($fileName);
|
||||
$pos = strpos($fileName, $bucketId . '/');
|
||||
|
||||
if ($pos === false) {
|
||||
throw new Exception('Invalid S3 path in filename');
|
||||
}
|
||||
|
||||
$s3Path = substr($fileName, $pos + strlen($bucketId) + 1);
|
||||
|
||||
// 3. Получаем FileID из Redis
|
||||
$redis = new Redis();
|
||||
if (!$redis->connect('crm.clientright.ru', 6379)) {
|
||||
throw new Exception('Redis connection failed');
|
||||
}
|
||||
|
||||
$redis->auth('CRM_Redis_Pass_2025_Secure!');
|
||||
|
||||
$redisKey = "crm:nc:fileid:" . $s3Path;
|
||||
$cached = $redis->get($redisKey);
|
||||
|
||||
if (!$cached) {
|
||||
$redis->close();
|
||||
throw new Exception('FileID not found in Redis index. Key: ' . substr($redisKey, 0, 100));
|
||||
}
|
||||
|
||||
$data = json_decode($cached, true);
|
||||
$fileId = $data['fileId'] ?? null;
|
||||
|
||||
$redis->close();
|
||||
|
||||
if (!$fileId) {
|
||||
throw new Exception('Invalid FileID data in Redis');
|
||||
}
|
||||
|
||||
// 4. Формируем URL для Nextcloud
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$ncPath = '/crm/' . $s3Path;
|
||||
$dirPath = dirname($ncPath);
|
||||
|
||||
$redirectUrl = $nextcloudUrl . '/apps/files/files/' . $fileId . '?dir=' . urlencode($dirPath) . '&openfile=true';
|
||||
|
||||
// 5. Возвращаем успешный ответ
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'fileId' => $fileId,
|
||||
'redirectUrl' => $redirectUrl,
|
||||
'source' => 'redis',
|
||||
'recordId' => $recordId
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'recordId' => $recordId
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
?>
|
||||
|
||||
|
||||
|
||||
36
crm_extensions/file_storage/check_paths_396447.php
Normal file
36
crm_extensions/file_storage/check_paths_396447.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8mb4",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
echo "ПРОВЕРКА ПУТЕЙ ПОСЛЕ ИСПРАВЛЕНИЯ:\n\n";
|
||||
|
||||
$sql = "SELECT notesid, s3_key FROM vtiger_notes n
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = 396447 AND n.s3_key IS NOT NULL
|
||||
ORDER BY notesid";
|
||||
$stmt = $pdo->query($sql);
|
||||
$docs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$allCorrect = true;
|
||||
foreach ($docs as $doc) {
|
||||
$hasPrefix = strpos($doc['s3_key'], 'crm2/CRM_Active_Files') === 0;
|
||||
$status = $hasPrefix ? '❌ С ПРЕФИКСОМ' : '✅ БЕЗ ПРЕФИКСА';
|
||||
echo sprintf("ID %-8s | %s\n", $doc['notesid'], $status);
|
||||
if ($hasPrefix) {
|
||||
$allCorrect = false;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
if ($allCorrect) {
|
||||
echo "✅ ВСЕ ДОКУМЕНТЫ ИМЕЮТ ЕДИНООБРАЗНЫЙ ФОРМАТ ПУТИ!\n";
|
||||
} else {
|
||||
echo "⚠️ ЕСТЬ ДОКУМЕНТЫ С ПРЕФИКСОМ\n";
|
||||
}
|
||||
|
||||
128
crm_extensions/file_storage/check_project_396447.php
Normal file
128
crm_extensions/file_storage/check_project_396447.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$projectId = 396447;
|
||||
|
||||
// Получаем информацию о проекте
|
||||
$sqlProject = "SELECT projectid, projectname, projectstatus FROM vtiger_project WHERE projectid = ?";
|
||||
$stmtProject = $pdo->prepare($sqlProject);
|
||||
$stmtProject->execute([$projectId]);
|
||||
$project = $stmtProject->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project) {
|
||||
die("❌ Проект $projectId не найден!\n");
|
||||
}
|
||||
|
||||
echo "📋 ПРОЕКТ: {$project['projectname']}\n";
|
||||
echo " ID: {$project['projectid']}\n";
|
||||
echo " Статус: {$project['projectstatus']}\n";
|
||||
echo "\n" . str_repeat("=", 80) . "\n\n";
|
||||
|
||||
// Получаем документы проекта
|
||||
$sql = "SELECT
|
||||
n.notesid,
|
||||
n.title,
|
||||
n.filename,
|
||||
n.filelocationtype,
|
||||
n.foldername,
|
||||
n.s3_key,
|
||||
n.nc_path,
|
||||
n.filesize,
|
||||
e.createdtime,
|
||||
e.modifiedtime,
|
||||
u.user_name,
|
||||
e.deleted
|
||||
FROM vtiger_notes n
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
LEFT JOIN vtiger_users u ON u.id = e.smownerid
|
||||
WHERE snr.crmid = ? AND e.deleted = 0
|
||||
ORDER BY e.createdtime DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$projectId]);
|
||||
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$count = count($documents);
|
||||
echo "📄 НАЙДЕНО ДОКУМЕНТОВ: $count\n\n";
|
||||
|
||||
if ($count == 0) {
|
||||
echo "⚠️ Документы не найдены!\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
$s3Count = 0;
|
||||
$localCount = 0;
|
||||
$brokenCount = 0;
|
||||
|
||||
foreach ($documents as $i => $doc) {
|
||||
$num = $i + 1;
|
||||
echo "[$num] ID: " . ($doc['notesid'] ?? 'N/A') . "\n";
|
||||
echo " Название: " . ($doc['title'] ?? 'не указано') . "\n";
|
||||
echo " Файл: " . ($doc['filename'] ?? 'не указано') . "\n";
|
||||
echo " Расположение: " . ($doc['filelocationtype'] ?: 'не указано') . "\n";
|
||||
|
||||
if (!empty($doc['s3_key'])) {
|
||||
echo " S3 Key: " . $doc['s3_key'] . "\n";
|
||||
$s3Count++;
|
||||
}
|
||||
|
||||
if (!empty($doc['nc_path'])) {
|
||||
echo " Nextcloud Path: " . $doc['nc_path'] . "\n";
|
||||
}
|
||||
|
||||
if (!empty($doc['foldername'])) {
|
||||
echo " Папка CRM: " . $doc['foldername'] . "\n";
|
||||
}
|
||||
|
||||
if (!empty($doc['filesize']) && $doc['filesize'] > 0) {
|
||||
$sizeKB = round($doc['filesize'] / 1024, 2);
|
||||
$sizeMB = round($doc['filesize'] / 1024 / 1024, 2);
|
||||
if ($sizeMB >= 1) {
|
||||
echo " Размер: {$sizeMB} MB\n";
|
||||
} else {
|
||||
echo " Размер: {$sizeKB} KB\n";
|
||||
}
|
||||
$totalSize += $doc['filesize'];
|
||||
} else {
|
||||
echo " Размер: не указан\n";
|
||||
}
|
||||
|
||||
echo " Создан: " . ($doc['createdtime'] ?? 'не указано') . "\n";
|
||||
echo " Изменён: " . ($doc['modifiedtime'] ?? 'не указано') . "\n";
|
||||
|
||||
if (!empty($doc['user_name'])) {
|
||||
echo " Владелец: " . $doc['user_name'] . "\n";
|
||||
}
|
||||
|
||||
// Проверка на битые файлы
|
||||
if (empty($doc['filename']) && empty($doc['s3_key'])) {
|
||||
echo " ВНИМАНИЕ: Файл без имени и пути!\n";
|
||||
$brokenCount++;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "📊 СТАТИСТИКА:\n";
|
||||
echo " Всего документов: $count\n";
|
||||
echo " В S3: $s3Count\n";
|
||||
echo " Локальных: " . ($count - $s3Count) . "\n";
|
||||
if ($brokenCount > 0) {
|
||||
echo " ⚠️ Битых (без файла): $brokenCount\n";
|
||||
}
|
||||
if ($totalSize > 0) {
|
||||
$totalMB = round($totalSize / 1024 / 1024, 2);
|
||||
echo " Общий размер: {$totalMB} MB\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
440
crm_extensions/file_storage/docs/AI_DOCUMENT_GENERATION_FLOW.md
Normal file
440
crm_extensions/file_storage/docs/AI_DOCUMENT_GENERATION_FLOW.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# 📄 Как AI Ассистент создает документы из шаблонов
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Полное описание процесса
|
||||
|
||||
## 🎯 Общий процесс (пошагово)
|
||||
|
||||
### ШАГ 1: Пользователь просит создать документ
|
||||
|
||||
**Пример запроса:**
|
||||
```
|
||||
Пользователь: "Создай претензию по заливу квартиры. Ущерб 400 тысяч рублей,
|
||||
ответчик УК Жилищник, клиент Иванов Иван Иванович"
|
||||
```
|
||||
|
||||
**Что происходит:**
|
||||
- Пользователь вводит запрос в AI Drawer
|
||||
- AI Drawer отправляет запрос в n8n через `/aiassist/n8n_proxy.php`
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 2: AI Drawer отправляет запрос в n8n
|
||||
|
||||
**Код в `ai-drawer-simple.js`:**
|
||||
```javascript
|
||||
// Пользователь нажал "Отправить"
|
||||
sendMessage() {
|
||||
const message = this.chatInput.value;
|
||||
this.sendToN8N(message);
|
||||
}
|
||||
|
||||
// Отправка в n8n
|
||||
async sendToN8N(message) {
|
||||
const context = this.getCurrentContext(); // Получаем данные проекта из CRM
|
||||
|
||||
const response = await fetch('/aiassist/n8n_proxy.php', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
context: context, // { projectId, module, userId, ... }
|
||||
sessionId: this.sessionId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// data.task_id - уникальный ID задачи
|
||||
// Подписываемся на SSE для получения ответа
|
||||
this.startSSEListener(data.task_id);
|
||||
}
|
||||
```
|
||||
|
||||
**Что отправляется в n8n:**
|
||||
```json
|
||||
{
|
||||
"message": "Создай претензию по заливу квартиры...",
|
||||
"context": {
|
||||
"projectId": "123456",
|
||||
"module": "Project",
|
||||
"userId": "42",
|
||||
"projectName": "Дело Иванова"
|
||||
},
|
||||
"sessionId": "ai-drawer-session-1234567890",
|
||||
"taskId": "task-691209e225894-1762789858",
|
||||
"redisChannel": "ai:response:task-691209e225894-1762789858"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 3: n8n обрабатывает запрос
|
||||
|
||||
**Workflow в n8n:**
|
||||
|
||||
```
|
||||
1. Webhook (получает запрос)
|
||||
↓
|
||||
2. AI Node (GPT-4) - анализирует запрос
|
||||
↓
|
||||
3. Определение типа документа
|
||||
↓
|
||||
4. Поиск данных в CRM (если нужно)
|
||||
↓
|
||||
5. Генерация текста документа
|
||||
↓
|
||||
6. Формирование переменных для шаблона
|
||||
↓
|
||||
7. HTTP Request → вызов API создания документа
|
||||
↓
|
||||
8. Публикация результата в Redis
|
||||
```
|
||||
|
||||
**Пример обработки в n8n:**
|
||||
|
||||
**3.1. AI анализирует запрос:**
|
||||
```
|
||||
Промпт для GPT:
|
||||
"Пользователь просит создать претензию.
|
||||
Проанализируй запрос и определи:
|
||||
- Тип документа (претензия/иск/жалоба)
|
||||
- Данные клиента
|
||||
- Данные ответчика
|
||||
- Сумму ущерба
|
||||
- Описание ситуации"
|
||||
```
|
||||
|
||||
**Ответ AI:**
|
||||
```json
|
||||
{
|
||||
"document_type": "pretenziya",
|
||||
"client_name": "Иванов Иван Иванович",
|
||||
"respondent_name": "УК Жилищник",
|
||||
"amount": "400000",
|
||||
"situation": "Залив квартиры от стояка ХВС",
|
||||
"claim_text": "УК отказывается возмещать ущерб..."
|
||||
}
|
||||
```
|
||||
|
||||
**3.2. Определение шаблона:**
|
||||
```javascript
|
||||
// В n8n workflow
|
||||
const templateMap = {
|
||||
'pretenziya': 'pretenziya.docx',
|
||||
'isk': 'iskovoe_zayavlenie.docx',
|
||||
'zhaloba': 'zhaloba.docx'
|
||||
};
|
||||
|
||||
const templateName = templateMap[aiResponse.document_type];
|
||||
// templateName = "pretenziya.docx"
|
||||
```
|
||||
|
||||
**3.3. Формирование переменных:**
|
||||
```javascript
|
||||
// В n8n workflow
|
||||
const variables = {
|
||||
CLIENT_NAME: aiResponse.client_name,
|
||||
RESPONDENT_NAME: aiResponse.respondent_name,
|
||||
DATE: new Date().toLocaleDateString('ru-RU'),
|
||||
AMOUNT: aiResponse.amount,
|
||||
CLAIM_TEXT: aiResponse.claim_text,
|
||||
SITUATION: aiResponse.situation
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 4: n8n вызывает API создания документа
|
||||
|
||||
**HTTP Request в n8n:**
|
||||
```javascript
|
||||
// URL
|
||||
https://crm.clientright.ru/crm_extensions/file_storage/api/create_from_template.php
|
||||
|
||||
// Метод: GET
|
||||
// Параметры:
|
||||
{
|
||||
module: "Project",
|
||||
recordId: "123456",
|
||||
recordName: "Дело_Иванова",
|
||||
fileName: "Претензия_УК_Жилищник",
|
||||
templateName: "pretenziya.docx",
|
||||
variables: JSON.stringify({
|
||||
CLIENT_NAME: "Иванов Иван Иванович",
|
||||
RESPONDENT_NAME: "УК Жилищник",
|
||||
DATE: "15.01.2025",
|
||||
AMOUNT: "400000",
|
||||
CLAIM_TEXT: "УК отказывается возмещать ущерб..."
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Полный URL:**
|
||||
```
|
||||
https://crm.clientright.ru/crm_extensions/file_storage/api/create_from_template.php?
|
||||
module=Project&
|
||||
recordId=123456&
|
||||
recordName=Дело_Иванова&
|
||||
fileName=Претензия_УК_Жилищник&
|
||||
templateName=pretenziya.docx&
|
||||
variables={"CLIENT_NAME":"Иванов Иван Иванович","DATE":"15.01.2025","AMOUNT":"400000",...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 5: API создает документ
|
||||
|
||||
**Что делает `create_from_template.php`:**
|
||||
|
||||
**5.1. Скачивает шаблон из Nextcloud:**
|
||||
```php
|
||||
// WebDAV запрос к Nextcloud
|
||||
$templateWebDAVUrl = 'https://office.clientright.ru:8443/remote.php/dav/files/admin/Templates/pretenziya.docx';
|
||||
|
||||
$ch = curl_init($templateWebDAVUrl);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "admin:office");
|
||||
$templateContent = curl_exec($ch);
|
||||
// Получили содержимое DOCX файла
|
||||
```
|
||||
|
||||
**5.2. Заполняет переменные:**
|
||||
```php
|
||||
// Шаблон содержит:
|
||||
// "Кому: {RESPONDENT_NAME}"
|
||||
// "От: {CLIENT_NAME}"
|
||||
// "Сумма: {AMOUNT} рублей"
|
||||
|
||||
// PHPWord заменяет переменные:
|
||||
$filledContent = fillDocxTemplate($templateContent, $variables);
|
||||
|
||||
// Результат:
|
||||
// "Кому: УК Жилищник"
|
||||
// "От: Иванов Иван Иванович"
|
||||
// "Сумма: 400000 рублей"
|
||||
```
|
||||
|
||||
**5.3. Сохраняет готовый документ:**
|
||||
```php
|
||||
// Сохраняет в S3
|
||||
$s3Path = "crm2/CRM_Active_Files/Documents/Project/Дело_Иванова_123456/Претензия_УК_Жилищник.docx";
|
||||
$s3Client->putObject([
|
||||
'Bucket' => '...',
|
||||
'Key' => $s3Path,
|
||||
'Body' => $filledContent
|
||||
]);
|
||||
```
|
||||
|
||||
**5.4. Открывает документ в OnlyOffice:**
|
||||
```php
|
||||
// Редирект на открытие файла
|
||||
header('Location: /crm_extensions/file_storage/api/open_file_v2.php?recordId=123456&fileName=...');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 6: n8n публикует результат в Redis
|
||||
|
||||
**После создания документа:**
|
||||
```javascript
|
||||
// В n8n workflow
|
||||
const result = {
|
||||
success: true,
|
||||
message: "Документ создан успешно",
|
||||
documentUrl: "https://s3.twcstorage.ru/.../Претензия_УК_Жилищник.docx",
|
||||
documentName: "Претензия_УК_Жилищник.docx"
|
||||
};
|
||||
|
||||
// Публикация в Redis
|
||||
redis.publish('ai:response:task-691209e225894-1762789858', JSON.stringify(result));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ШАГ 7: AI Drawer получает ответ
|
||||
|
||||
**SSE слушает Redis:**
|
||||
```javascript
|
||||
// В ai-drawer-simple.js
|
||||
startSSEListener(taskId) {
|
||||
const eventSource = new EventSource(`/aiassist/ai_sse.php?task_id=${taskId}`);
|
||||
|
||||
eventSource.addEventListener('response', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.success) {
|
||||
// Показываем сообщение пользователю
|
||||
this.addMessage('assistant', `✅ Документ создан: ${data.documentName}`);
|
||||
|
||||
// Можно добавить кнопку для открытия документа
|
||||
this.addDocumentLink(data.documentUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Пользователь видит:**
|
||||
```
|
||||
✅ Документ создан: Претензия_УК_Жилищник.docx
|
||||
[Открыть документ] ← кнопка
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Визуальная схема процесса
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Пользователь│
|
||||
│ AI Drawer │
|
||||
└──────┬──────┘
|
||||
│ "Создай претензию..."
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ n8n_proxy.php │
|
||||
│ (генерирует │
|
||||
│ task_id) │
|
||||
└──────┬───────────┘
|
||||
│ POST {message, context}
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ n8n Workflow │
|
||||
│ │
|
||||
│ 1. AI анализирует│
|
||||
│ 2. Определяет тип│
|
||||
│ 3. Генерирует данные│
|
||||
│ 4. Вызывает API │
|
||||
└──────┬───────────┘
|
||||
│ GET /create_from_template.php
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ create_from_ │
|
||||
│ template.php │
|
||||
│ │
|
||||
│ 1. Скачивает │
|
||||
│ шаблон │
|
||||
│ 2. Заполняет │
|
||||
│ переменные │
|
||||
│ 3. Сохраняет │
|
||||
│ в S3 │
|
||||
│ 4. Открывает │
|
||||
│ в OnlyOffice │
|
||||
└──────┬───────────┘
|
||||
│ Результат
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ Redis Pub/Sub │
|
||||
│ ai:response: │
|
||||
│ {taskId} │
|
||||
└──────┬───────────┘
|
||||
│ SSE событие
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ AI Drawer (SSE) │
|
||||
│ Показывает │
|
||||
│ результат │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Пример полного запроса
|
||||
|
||||
### Запрос пользователя:
|
||||
```
|
||||
"Создай претензию по заливу квартиры. Ущерб 400 тысяч рублей,
|
||||
ответчик УК Жилищник, клиент Иванов Иван Иванович"
|
||||
```
|
||||
|
||||
### Что происходит:
|
||||
|
||||
1. **AI Drawer → n8n:**
|
||||
```json
|
||||
{
|
||||
"message": "Создай претензию...",
|
||||
"context": {"projectId": "123456", "module": "Project"}
|
||||
}
|
||||
```
|
||||
|
||||
2. **n8n → AI (GPT-4):**
|
||||
```
|
||||
"Проанализируй запрос и определи тип документа и данные"
|
||||
```
|
||||
|
||||
3. **AI → n8n:**
|
||||
```json
|
||||
{
|
||||
"document_type": "pretenziya",
|
||||
"client_name": "Иванов Иван Иванович",
|
||||
"respondent_name": "УК Жилищник",
|
||||
"amount": "400000"
|
||||
}
|
||||
```
|
||||
|
||||
4. **n8n → API создания документа:**
|
||||
```
|
||||
GET /create_from_template.php?
|
||||
module=Project&
|
||||
recordId=123456&
|
||||
fileName=Претензия_УК_Жилищник&
|
||||
templateName=pretenziya.docx&
|
||||
variables={"CLIENT_NAME":"Иванов Иван Иванович",...}
|
||||
```
|
||||
|
||||
5. **API → Nextcloud:**
|
||||
```
|
||||
WebDAV GET /Templates/pretenziya.docx
|
||||
```
|
||||
|
||||
6. **API → PHPWord:**
|
||||
```
|
||||
Заменяет {CLIENT_NAME} → "Иванов Иван Иванович"
|
||||
Заменяет {AMOUNT} → "400000"
|
||||
...
|
||||
```
|
||||
|
||||
7. **API → S3:**
|
||||
```
|
||||
PUT crm2/CRM_Active_Files/Documents/Project/.../Претензия_УК_Жилищник.docx
|
||||
```
|
||||
|
||||
8. **n8n → Redis:**
|
||||
```
|
||||
PUBLISH ai:response:task-xxx {"success": true, "documentUrl": "..."}
|
||||
```
|
||||
|
||||
9. **SSE → AI Drawer:**
|
||||
```
|
||||
Показывает: "✅ Документ создан: Претензия_УК_Жилищник.docx"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Ключевые моменты
|
||||
|
||||
1. **AI не создает документ напрямую** - он только анализирует запрос и генерирует данные
|
||||
2. **n8n координирует процесс** - вызывает API создания документа
|
||||
3. **API работает с шаблонами** - скачивает, заполняет, сохраняет
|
||||
4. **Результат возвращается через SSE** - пользователь видит ответ в реальном времени
|
||||
|
||||
## 🎯 Преимущества такого подхода
|
||||
|
||||
✅ **Разделение ответственности:**
|
||||
- AI анализирует и генерирует данные
|
||||
- n8n координирует процесс
|
||||
- API работает с файлами
|
||||
|
||||
✅ **Гибкость:**
|
||||
- Легко добавить новые типы документов
|
||||
- Легко изменить шаблоны
|
||||
- Легко добавить новые источники данных
|
||||
|
||||
✅ **Надежность:**
|
||||
- Каждый компонент можно тестировать отдельно
|
||||
- Ошибки изолированы
|
||||
- Легко отлаживать
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
205
crm_extensions/file_storage/docs/AI_DOCUMENT_TOOL.md
Normal file
205
crm_extensions/file_storage/docs/AI_DOCUMENT_TOOL.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 🛠️ Инструмент для AI: Создание документов
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Готово к использованию
|
||||
|
||||
## 🎯 Назначение
|
||||
|
||||
Простой инструмент для AI Ассистента, который:
|
||||
1. Создает пустой DOCX/XLSX/PPTX файл
|
||||
2. Записывает в него текст, сгенерированный AI
|
||||
3. Сохраняет в папку проекта
|
||||
4. Возвращает ссылку на редактирование
|
||||
|
||||
## 📍 Endpoint
|
||||
|
||||
```
|
||||
POST /crm_extensions/file_storage/api/create_document_with_text.php
|
||||
```
|
||||
|
||||
## 📥 Параметры запроса
|
||||
|
||||
### Обязательные:
|
||||
- `module` - модуль CRM (Project, Contacts, Accounts, etc.)
|
||||
- `recordId` - ID записи (проекта, контакта и т.д.)
|
||||
- `recordName` - название записи (для формирования папки)
|
||||
- `fileName` - имя создаваемого файла (без расширения)
|
||||
- `documentText` - текст документа, который нужно записать
|
||||
|
||||
### Опциональные:
|
||||
- `documentType` - тип документа: `docx` (по умолчанию), `xlsx`, `pptx`
|
||||
|
||||
## 📤 Ответ
|
||||
|
||||
### Успешный ответ:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Документ создан успешно",
|
||||
"documentName": "Претензия_УК_Жилищник.docx",
|
||||
"documentUrl": "https://s3.twcstorage.ru/.../Претензия_УК_Жилищник.docx",
|
||||
"editUrl": "https://crm.clientright.ru/crm_extensions/file_storage/api/open_file_v2.php?recordId=123456&fileName=...",
|
||||
"path": "crm2/CRM_Active_Files/Documents/Project/Дело_Иванова_123456/Претензия_УК_Жилищник.docx"
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибка:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Не указаны обязательные параметры: module, recordId, fileName, documentText"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Использование в n8n
|
||||
|
||||
### Пример HTTP Request узла в n8n:
|
||||
|
||||
**URL:**
|
||||
```
|
||||
https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php
|
||||
```
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"module": "{{ $json.body.context.module }}",
|
||||
"recordId": "{{ $json.body.context.projectId }}",
|
||||
"recordName": "{{ $json.body.context.projectName }}",
|
||||
"fileName": "{{ $json.body.documentName }}",
|
||||
"documentText": "{{ $json.body.generatedText }}",
|
||||
"documentType": "docx"
|
||||
}
|
||||
```
|
||||
|
||||
**Или через Query Parameters (GET):**
|
||||
```
|
||||
https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php?
|
||||
module={{ $json.body.context.module }}&
|
||||
recordId={{ $json.body.context.projectId }}&
|
||||
recordName={{ $json.body.context.projectName }}&
|
||||
fileName={{ $json.body.documentName }}&
|
||||
documentText={{ $json.body.generatedText }}&
|
||||
documentType=docx
|
||||
```
|
||||
|
||||
## 📋 Пример полного workflow в n8n
|
||||
|
||||
```
|
||||
1. Webhook (получает запрос от AI Drawer)
|
||||
↓
|
||||
2. AI Node (GPT-4) - генерирует текст документа
|
||||
↓
|
||||
3. HTTP Request → create_document_with_text.php
|
||||
Body: {
|
||||
module: "Project",
|
||||
recordId: "123456",
|
||||
recordName: "Дело Иванова",
|
||||
fileName: "Претензия_УК_Жилищник",
|
||||
documentText: "ПРЕТЕНЗИЯ\n\nКому: УК Жилищник\nОт: Иванов Иван Иванович\n\n..."
|
||||
}
|
||||
↓
|
||||
4. Получаем ответ:
|
||||
{
|
||||
success: true,
|
||||
documentName: "Претензия_УК_Жилищник.docx",
|
||||
editUrl: "https://..."
|
||||
}
|
||||
↓
|
||||
5. Формируем сообщение для пользователя:
|
||||
"✅ Документ создан: Претензия_УК_Жилищник.docx\n[Открыть для редактирования]"
|
||||
↓
|
||||
6. Публикуем в Redis: ai:response:{taskId}
|
||||
```
|
||||
|
||||
## 💬 Пример ответа AI пользователю
|
||||
|
||||
**После создания документа:**
|
||||
```
|
||||
✅ Документ создан: Претензия_УК_Жилищник.docx
|
||||
|
||||
Документ сохранен в папку проекта и готов к редактированию.
|
||||
Вы можете открыть его для просмотра и внесения изменений.
|
||||
|
||||
[Открыть документ] ← ссылка на editUrl
|
||||
```
|
||||
|
||||
## 🎨 Форматирование текста
|
||||
|
||||
### DOCX:
|
||||
- Текст разбивается на параграфы по переносам строк (`\n`)
|
||||
- Каждый параграф отделяется пустой строкой
|
||||
- Шрифт: Times New Roman, 12pt
|
||||
- Поля: 2 см сверху/справа/снизу, 3 см слева
|
||||
|
||||
### XLSX:
|
||||
- Весь текст записывается в ячейку A1
|
||||
- Автоподбор ширины колонки
|
||||
|
||||
### PPTX:
|
||||
- Текст размещается на первом слайде
|
||||
- Разбивается на параграфы
|
||||
|
||||
## 🔍 Примеры использования
|
||||
|
||||
### Пример 1: Создание претензии
|
||||
|
||||
**Запрос в n8n:**
|
||||
```json
|
||||
{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Дело Иванова",
|
||||
"fileName": "Претензия_УК_Жилищник",
|
||||
"documentText": "ПРЕТЕНЗИЯ\n\nКому: УК \"Жилищник\"\nОт: Иванов Иван Иванович\n\nДата: 15.01.2025\n\nТекст претензии:\nУК отказывается возмещать ущерб от залива квартиры...\n\nТребования:\n1. Возместить ущерб в размере 400000 рублей\n2. Провести экспертизу\n\nС уважением,\nИванов Иван Иванович"
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Создан файл `Претензия_УК_Жилищник.docx`
|
||||
- Сохранен в папку проекта
|
||||
- Возвращена ссылка на редактирование
|
||||
|
||||
### Пример 2: Создание иска
|
||||
|
||||
**Запрос:**
|
||||
```json
|
||||
{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Дело Иванова",
|
||||
"fileName": "Исковое_заявление",
|
||||
"documentText": "ИСКОВОЕ ЗАЯВЛЕНИЕ\n\nВ суд: ...\n\nИстец: Иванов Иван Иванович\nОтветчик: УК \"Жилищник\"\n\n...",
|
||||
"documentType": "docx"
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ Ограничения
|
||||
|
||||
1. **Максимальный размер текста:** Ограничен памятью PHP (обычно 128MB+)
|
||||
2. **Форматирование:** Базовое форматирование (параграфы, переносы строк)
|
||||
3. **Таблицы/изображения:** Не поддерживаются в упрощенной версии
|
||||
|
||||
## 🚀 Следующие шаги
|
||||
|
||||
После MVP можно добавить:
|
||||
1. Поддержку шаблонов (заполнение переменных)
|
||||
2. Расширенное форматирование (жирный, курсив, списки)
|
||||
3. Таблицы и изображения
|
||||
4. Автоматическое определение типа документа
|
||||
|
||||
## 📝 Примечания
|
||||
|
||||
- Файл сохраняется в S3
|
||||
- Событие публикуется в Redis для индексации
|
||||
- Документ сразу доступен для редактирования в OnlyOffice
|
||||
- Путь формируется автоматически: `{module}/{recordName}_{recordId}/{fileName}.{ext}`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
200
crm_extensions/file_storage/docs/AI_DOCUMENT_TOOL_INSTRUCTION.md
Normal file
200
crm_extensions/file_storage/docs/AI_DOCUMENT_TOOL_INSTRUCTION.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 📄 Инструмент создания документов для AI Ассистента
|
||||
|
||||
## Описание
|
||||
|
||||
Создает документ (DOCX/XLSX/PPTX) с текстом, сгенерированным AI, и сохраняет его в папку проекта в CRM. Документ сразу доступен для редактирования в OnlyOffice.
|
||||
|
||||
**Процесс:**
|
||||
1. Создает пустой документ выбранного типа (DOCX по умолчанию)
|
||||
2. Записывает в него текст, сгенерированный AI
|
||||
3. Сохраняет в S3 в папку проекта: `{module}/{recordName}_{recordId}/{fileName}.{ext}`
|
||||
4. Публикует событие в Redis для индексации
|
||||
5. Возвращает ссылку на редактирование в OnlyOffice
|
||||
|
||||
**Форматирование:**
|
||||
- DOCX: текст разбивается на параграфы по переносам строк (`\n`), шрифт Times New Roman 12pt, стандартные поля
|
||||
- XLSX: весь текст записывается в ячейку A1
|
||||
- PPTX: текст размещается на первом слайде
|
||||
|
||||
## Входные параметры
|
||||
|
||||
**URL:** `https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php`
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
### Обязательные параметры:
|
||||
|
||||
- `module` (string) — модуль CRM, где создается документ:
|
||||
- `"Project"` — для проектов
|
||||
- `"Contacts"` — для контактов
|
||||
- `"Accounts"` — для организаций
|
||||
- `"Invoice"`, `"Quotes"`, `"SalesOrder"`, `"PurchaseOrder"`, `"HelpDesk"`, `"Leads"`, `"Potentials"` — для других модулей
|
||||
|
||||
- `recordId` (string) — ID записи в CRM (проекта, контакта и т.д.), к которой привязывается документ
|
||||
|
||||
- `recordName` (string) — название записи (используется для формирования имени папки). Спецсимволы будут заменены на подчеркивания
|
||||
|
||||
- `fileName` (string) — имя создаваемого файла без расширения (например: `"Претензия_УК_Жилищник"`). Расширение добавится автоматически
|
||||
|
||||
- `documentText` (string) — текст документа, который нужно записать. **Поддерживается Markdown форматирование:**
|
||||
- Заголовки: `# H1`, `## H2`, `### H3`
|
||||
- Жирный: `**текст**` или `__текст__`
|
||||
- Курсив: `*текст*` или `_текст_`
|
||||
- Код: `` `текст` ``
|
||||
- Маркированные списки: `- пункт` или `* пункт`
|
||||
- Нумерованные списки: `1. пункт`
|
||||
- Поддерживаются переносы строк (`\n`) для разделения на параграфы
|
||||
|
||||
### Опциональные параметры:
|
||||
|
||||
- `documentType` (string, по умолчанию `"docx"`) — тип документа:
|
||||
- `"docx"` — Word документ (рекомендуется для текстовых документов)
|
||||
- `"xlsx"` — Excel таблица (для табличных данных)
|
||||
- `"pptx"` — PowerPoint презентация (для презентаций)
|
||||
|
||||
## Что возвращает
|
||||
|
||||
### Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Документ создан успешно",
|
||||
"documentName": "Претензия_УК_Жилищник.docx",
|
||||
"documentUrl": "https://s3.twcstorage.ru/bucket/path/to/file.docx",
|
||||
"editUrl": "https://crm.clientright.ru/crm_extensions/file_storage/api/open_file_v2.php?recordId=123456&fileName=https://s3.twcstorage.ru/...",
|
||||
"path": "crm2/CRM_Active_Files/Documents/Project/Дело_Иванова_123456/Претензия_УК_Жилищник.docx"
|
||||
}
|
||||
```
|
||||
|
||||
**Поля ответа:**
|
||||
- `success` (boolean) — `true` если документ создан успешно
|
||||
- `message` (string) — сообщение о результате
|
||||
- `documentName` (string) — имя созданного файла с расширением
|
||||
- `documentUrl` (string) — прямой URL файла в S3
|
||||
- `editUrl` (string) — URL для открытия документа в OnlyOffice (используй эту ссылку для пользователя)
|
||||
- `path` (string) — путь к файлу в S3 (для внутреннего использования)
|
||||
|
||||
### Ошибка:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Не указаны обязательные параметры: module, recordId, fileName, documentText"
|
||||
}
|
||||
```
|
||||
|
||||
## Когда использовать
|
||||
|
||||
**Используй этот инструмент когда:**
|
||||
|
||||
1. Пользователь просит создать документ (претензию, иск, жалобу, ходатайство и т.д.)
|
||||
2. Ты уже сгенерировал текст документа и готов его сохранить
|
||||
3. Нужно сохранить документ в папку проекта в CRM
|
||||
4. Пользователь должен иметь возможность редактировать документ в OnlyOffice
|
||||
|
||||
**Примеры запросов пользователя:**
|
||||
- "Создай претензию по заливу квартиры"
|
||||
- "Подготовь исковое заявление"
|
||||
- "Сформируй жалобу в прокуратуру"
|
||||
- "Напиши ходатайство о приостановлении дела"
|
||||
|
||||
**Не используй когда:**
|
||||
- Пользователь просто спрашивает информацию (без создания документа)
|
||||
- Нужно только показать текст без сохранения
|
||||
- Документ должен быть создан из шаблона с переменными (используй другой инструмент)
|
||||
|
||||
## Пример использования
|
||||
|
||||
### Запрос пользователя:
|
||||
```
|
||||
"Создай претензию по заливу квартиры. Ущерб 400 тысяч рублей,
|
||||
ответчик УК Жилищник, клиент Иванов Иван Иванович"
|
||||
```
|
||||
|
||||
### Твой ответ (после генерации текста):
|
||||
|
||||
**1. Вызываешь инструмент:**
|
||||
```json
|
||||
POST /crm_extensions/file_storage/api/create_document_with_text.php
|
||||
{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Дело Иванова",
|
||||
"fileName": "Претензия_УК_Жилищник",
|
||||
"documentText": "# ПРЕТЕНЗИЯ\n\nКому: **УК \\\"Жилищник\\\"**\nОт: *Иванов Иван Иванович*\n\nДата: 15.01.2025\n\n## Текст претензии\n\nУК отказывается возмещать ущерб от залива квартиры от стояка ХВС. Ущерб составляет `400000` рублей.\n\n## Требования:\n\n1. Возместить ущерб в размере **400000 рублей**\n2. Провести экспертизу для оценки ущерба\n\nС уважением,\n**Иванов Иван Иванович**"
|
||||
}
|
||||
```
|
||||
|
||||
**2. Получаешь ответ:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"documentName": "Претензия_УК_Жилищник.docx",
|
||||
"editUrl": "https://crm.clientright.ru/crm_extensions/file_storage/api/open_file_v2.php?recordId=123456&fileName=..."
|
||||
}
|
||||
```
|
||||
|
||||
**3. Сообщаешь пользователю:**
|
||||
```
|
||||
✅ Документ создан: Претензия_УК_Жилищник.docx
|
||||
|
||||
Документ сохранен в папку проекта и готов к редактированию.
|
||||
Вы можете открыть его для просмотра и внесения изменений.
|
||||
|
||||
[Открыть документ](editUrl)
|
||||
```
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Всегда используй `editUrl`** для ссылки пользователю — это откроет документ в OnlyOffice
|
||||
2. **Имя файла должно быть уникальным** — если файл с таким именем уже существует, он будет перезаписан
|
||||
3. **Текст документа** должен быть готовым к использованию — инструмент не редактирует текст, только записывает его
|
||||
4. **Переносы строк** (`\n`) в `documentText` создают новые параграфы в DOCX
|
||||
5. **Документ сохраняется сразу** — откатить операцию нельзя, убедись что данные корректны
|
||||
|
||||
## Поддержка форматирования
|
||||
|
||||
**API поддерживает Markdown форматирование:**
|
||||
|
||||
- ✅ **Заголовки**: `# H1`, `## H2`, `### H3` — автоматически форматируются как заголовки
|
||||
- ✅ **Жирный текст**: `**текст**` или `__текст__` — выделение важной информации
|
||||
- ✅ **Курсив**: `*текст*` или `_текст_` — акценты
|
||||
- ✅ **Код**: `` `текст` `` — статьи, суммы, технические данные (Courier New, синий)
|
||||
- ✅ **Маркированные списки**: `- пункт` или `* пункт` — автоматические отступы
|
||||
- ✅ **Нумерованные списки**: `1. пункт` — автоматическая нумерация
|
||||
|
||||
**Пример использования:**
|
||||
```markdown
|
||||
# ПРЕТЕНЗИЯ
|
||||
|
||||
Кому: **УК "Жилищник"**
|
||||
От: *Иванов Иван Иванович*
|
||||
|
||||
## Требования:
|
||||
|
||||
1. Возместить ущерб **400000 рублей**
|
||||
2. Провести экспертизу
|
||||
|
||||
- Дополнительно
|
||||
- Еще пункт
|
||||
```
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Максимальный размер текста ограничен памятью PHP (обычно 128MB+)
|
||||
- Таблицы не поддерживаются (можно использовать списки)
|
||||
- Изображения не поддерживаются
|
||||
- Вложенные списки не поддерживаются (только один уровень)
|
||||
- Ссылки не поддерживаются (можно использовать код `` `текст` ``)
|
||||
|
||||
## Следующие шаги после создания
|
||||
|
||||
После создания документа пользователь может:
|
||||
1. Открыть документ по ссылке `editUrl` в OnlyOffice
|
||||
2. Редактировать текст, форматирование, добавлять таблицы и изображения
|
||||
3. Сохранить изменения (автоматически сохраняется в S3)
|
||||
4. Экспортировать в PDF через OnlyOffice
|
||||
|
||||
252
crm_extensions/file_storage/docs/MARKDOWN_FORMATTING.md
Normal file
252
crm_extensions/file_storage/docs/MARKDOWN_FORMATTING.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 📝 Поддержка форматирования Markdown в документах
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Реализовано
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
API создания документов теперь поддерживает **Markdown форматирование**! AI может использовать стандартный Markdown синтаксис для создания красиво оформленных документов.
|
||||
|
||||
## ✨ Поддерживаемые элементы форматирования
|
||||
|
||||
### 1. Заголовки
|
||||
|
||||
```markdown
|
||||
# Заголовок 1 уровня (H1) - размер 18pt, жирный
|
||||
## Заголовок 2 уровня (H2) - размер 16pt, жирный
|
||||
### Заголовок 3 уровня (H3) - размер 14pt, жирный
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
# ПРЕТЕНЗИЯ
|
||||
## Текст претензии
|
||||
### Требования
|
||||
```
|
||||
|
||||
### 2. Жирный текст
|
||||
|
||||
```markdown
|
||||
**жирный текст**
|
||||
__жирный текст__
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
Кому: **УК "Жилищник"**
|
||||
Сумма: __400000 рублей__
|
||||
```
|
||||
|
||||
### 3. Курсив
|
||||
|
||||
```markdown
|
||||
*курсив*
|
||||
_курсив_
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
От: *Иванов Иван Иванович*
|
||||
Дата: _15.01.2025_
|
||||
```
|
||||
|
||||
### 4. Выделение кода
|
||||
|
||||
```markdown
|
||||
`код`
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
Сумма ущерба: `400000` рублей
|
||||
Статья: `ст. 1064 ГК РФ`
|
||||
```
|
||||
|
||||
### 5. Маркированные списки
|
||||
|
||||
```markdown
|
||||
- Первый пункт
|
||||
- Второй пункт
|
||||
- Третий пункт
|
||||
|
||||
* Альтернативный маркер
|
||||
* Еще один пункт
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
Требования:
|
||||
- Возместить ущерб
|
||||
- Провести экспертизу
|
||||
- Подготовить документы
|
||||
```
|
||||
|
||||
### 6. Нумерованные списки
|
||||
|
||||
```markdown
|
||||
1. Первый пункт
|
||||
2. Второй пункт
|
||||
3. Третий пункт
|
||||
```
|
||||
|
||||
**Пример:**
|
||||
```markdown
|
||||
Порядок действий:
|
||||
1. Подать претензию
|
||||
2. Дождаться ответа
|
||||
3. При необходимости обратиться в суд
|
||||
```
|
||||
|
||||
## 📋 Пример полного документа с форматированием
|
||||
|
||||
```markdown
|
||||
# ПРЕТЕНЗИЯ
|
||||
|
||||
## Заголовок раздела
|
||||
|
||||
Кому: **УК "Жилищник"**
|
||||
От: *Иванов Иван Иванович*
|
||||
|
||||
Дата: 15.01.2025
|
||||
|
||||
### Текст претензии
|
||||
|
||||
УК отказывается возмещать ущерб от залива квартиры от стояка ХВС.
|
||||
Ущерб составляет `400000` рублей.
|
||||
|
||||
### Требования:
|
||||
|
||||
1. Возместить ущерб в размере **400000 рублей**
|
||||
2. Провести экспертизу для оценки ущерба
|
||||
3. Возместить моральный вред
|
||||
|
||||
### Дополнительно:
|
||||
|
||||
- Провести экспертизу
|
||||
- Оценить ущерб
|
||||
- Подготовить документы
|
||||
|
||||
С уважением,
|
||||
**Иванов Иван Иванович**
|
||||
```
|
||||
|
||||
## 🎨 Как это выглядит в документе
|
||||
|
||||
### Заголовки:
|
||||
- **H1** (#) — крупный заголовок, 18pt, жирный, отступ сверху
|
||||
- **H2** (##) — средний заголовок, 16pt, жирный
|
||||
- **H3** (###) — маленький заголовок, 14pt, жирный
|
||||
|
||||
### Текст:
|
||||
- **Жирный** — выделение важной информации
|
||||
- *Курсив* — акценты, названия
|
||||
- `Код` — статьи, суммы, технические данные (Courier New, синий цвет)
|
||||
|
||||
### Списки:
|
||||
- Маркированные — с символом •, отступ слева
|
||||
- Нумерованные — с автоматической нумерацией, отступ слева
|
||||
|
||||
## 💡 Рекомендации для AI
|
||||
|
||||
### Когда использовать форматирование:
|
||||
|
||||
1. **Заголовки** — для структурирования документа:
|
||||
```markdown
|
||||
# ПРЕТЕНЗИЯ
|
||||
## Текст претензии
|
||||
## Требования
|
||||
## Приложения
|
||||
```
|
||||
|
||||
2. **Жирный текст** — для важной информации:
|
||||
```markdown
|
||||
Кому: **УК "Жилищник"**
|
||||
Сумма: **400000 рублей**
|
||||
```
|
||||
|
||||
3. **Списки** — для перечислений:
|
||||
```markdown
|
||||
Требования:
|
||||
1. Возместить ущерб
|
||||
2. Провести экспертизу
|
||||
```
|
||||
|
||||
4. **Код** — для статей, сумм, ссылок:
|
||||
```markdown
|
||||
Ссылка на право: `ст. 1064 ГК РФ`
|
||||
Сумма: `400000` рублей
|
||||
```
|
||||
|
||||
### Пример использования в AI ответе:
|
||||
|
||||
```markdown
|
||||
# ПРЕТЕНЗИЯ
|
||||
|
||||
Кому: **УК "Жилищник"**
|
||||
От: *Иванов Иван Иванович*
|
||||
|
||||
Дата: 15.01.2025
|
||||
|
||||
## Текст претензии
|
||||
|
||||
УК отказывается возмещать ущерб от залива квартиры от стояка ХВС.
|
||||
Ущерб составляет `400000` рублей.
|
||||
|
||||
## Требования:
|
||||
|
||||
1. Возместить ущерб в размере **400000 рублей**
|
||||
2. Провести экспертизу для оценки ущерба
|
||||
3. Возместить моральный вред
|
||||
|
||||
## Ссылки на право:
|
||||
|
||||
- `ст. 1064 ГК РФ` - общие основания ответственности за вред
|
||||
- `ст. 15 ГК РФ` - возмещение убытков
|
||||
|
||||
С уважением,
|
||||
**Иванов Иван Иванович**
|
||||
```
|
||||
|
||||
## ⚠️ Ограничения
|
||||
|
||||
1. **Вложенные списки** — не поддерживаются (только один уровень)
|
||||
2. **Таблицы** — не поддерживаются (можно использовать списки)
|
||||
3. **Изображения** — не поддерживаются
|
||||
4. **Ссылки** — не поддерживаются (можно использовать код `[текст](url)`)
|
||||
5. **Комбинированное форматирование** — `**жирный *курсив* текст**` работает частично
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
- Парсинг выполняется построчно
|
||||
- Поддерживается комбинирование форматирования в одном параграфе
|
||||
- Списки автоматически завершаются при появлении обычного текста
|
||||
- Пустые строки создают отступы между блоками
|
||||
|
||||
## 📚 Справочник Markdown для AI
|
||||
|
||||
Используй эти элементы при генерации документов:
|
||||
|
||||
| Элемент | Синтаксис | Пример |
|
||||
|---------|-----------|--------|
|
||||
| Заголовок H1 | `# Текст` | `# ПРЕТЕНЗИЯ` |
|
||||
| Заголовок H2 | `## Текст` | `## Требования` |
|
||||
| Заголовок H3 | `### Текст` | `### Дополнительно` |
|
||||
| Жирный | `**текст**` | `**400000 рублей**` |
|
||||
| Курсив | `*текст*` | `*Иванов Иван Иванович*` |
|
||||
| Код | `` `текст` `` | `` `ст. 1064 ГК РФ` `` |
|
||||
| Маркированный список | `- пункт` | `- Первый пункт` |
|
||||
| Нумерованный список | `1. пункт` | `1. Первый пункт` |
|
||||
|
||||
## ✅ Преимущества
|
||||
|
||||
1. **Стандартный синтаксис** — Markdown понимают все AI модели
|
||||
2. **Читаемость** — легко читать и редактировать
|
||||
3. **Гибкость** — можно комбинировать элементы
|
||||
4. **Автоматическое форматирование** — документ получается красивым без ручной правки
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
160
crm_extensions/file_storage/docs/N8N_HTTP_REQUEST_CURL.md
Normal file
160
crm_extensions/file_storage/docs/N8N_HTTP_REQUEST_CURL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 🔧 cURL для n8n HTTP Request ноды
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Назначение:** Тестирование и настройка HTTP Request ноды в n8n
|
||||
|
||||
## 📍 Endpoint
|
||||
|
||||
```
|
||||
POST https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php
|
||||
```
|
||||
|
||||
## 🔧 cURL команда для тестирования
|
||||
|
||||
### Базовый пример:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Тестовый проект",
|
||||
"fileName": "Тестовый_документ",
|
||||
"documentText": "ПРЕТЕНЗИЯ\n\nКому: УК Жилищник\nОт: Иванов Иван Иванович\n\nДата: 15.01.2025\n\nТекст претензии:\nУК отказывается возмещать ущерб от залива квартиры.\n\nТребования:\n1. Возместить ущерб в размере 400000 рублей\n2. Провести экспертизу\n\nС уважением,\nИванов Иван Иванович"
|
||||
}'
|
||||
```
|
||||
|
||||
### С documentType:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Тестовый проект",
|
||||
"fileName": "Тестовый_документ",
|
||||
"documentText": "ПРЕТЕНЗИЯ\n\nКому: УК Жилищник\nОт: Иванов Иван Иванович\n\n...",
|
||||
"documentType": "docx"
|
||||
}'
|
||||
```
|
||||
|
||||
## 📋 Для импорта в n8n HTTP Request ноду
|
||||
|
||||
### Настройки ноды:
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**URL:**
|
||||
```
|
||||
https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php
|
||||
```
|
||||
|
||||
**Authentication:** None
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"module": "{{ $json.body.context.module }}",
|
||||
"recordId": "{{ $json.body.context.projectId }}",
|
||||
"recordName": "{{ $json.body.context.projectName }}",
|
||||
"fileName": "{{ $json.body.documentName }}",
|
||||
"documentText": "{{ $json.body.generatedText }}",
|
||||
"documentType": "docx"
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Примеры с реальными данными
|
||||
|
||||
### Пример 1: Претензия
|
||||
|
||||
```bash
|
||||
curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "Project",
|
||||
"recordId": "390657",
|
||||
"recordName": "Дело Иванова",
|
||||
"fileName": "Претензия_УК_Жилищник",
|
||||
"documentText": "ПРЕТЕНЗИЯ\n\nКому: УК \"Жилищник\"\nОт: Иванов Иван Иванович\n\nДата: 15.01.2025\n\nТекст претензии:\nУК отказывается возмещать ущерб от залива квартиры от стояка ХВС. Ущерб составляет 400000 рублей.\n\nТребования:\n1. Возместить ущерб в размере 400000 рублей\n2. Провести экспертизу для оценки ущерба\n3. Возместить моральный вред\n\nС уважением,\nИванов Иван Иванович"
|
||||
}'
|
||||
```
|
||||
|
||||
### Пример 2: Исковое заявление
|
||||
|
||||
```bash
|
||||
curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "Project",
|
||||
"recordId": "390657",
|
||||
"recordName": "Дело Иванова",
|
||||
"fileName": "Исковое_заявление",
|
||||
"documentText": "ИСКОВОЕ ЗАЯВЛЕНИЕ\n\nВ суд: Районный суд г. Москвы\n\nИстец: Иванов Иван Иванович\nОтветчик: УК \"Жилищник\"\n\nЦена иска: 400000 рублей\n\nИсковые требования:\n1. Взыскать с ответчика 400000 рублей в счет возмещения ущерба\n2. Взыскать госпошлину\n\nОбстоятельства дела:\n..."
|
||||
}'
|
||||
```
|
||||
|
||||
## 🔍 Проверка ответа
|
||||
|
||||
### Успешный ответ:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Документ создан успешно",
|
||||
"documentName": "Претензия_УК_Жилищник.docx",
|
||||
"documentUrl": "https://s3.twcstorage.ru/.../Претензия_УК_Жилищник.docx",
|
||||
"editUrl": "https://crm.clientright.ru/crm_extensions/file_storage/api/open_file_v2.php?recordId=123456&fileName=...",
|
||||
"path": "crm2/CRM_Active_Files/Documents/Project/Дело_Иванова_123456/Претензия_УК_Жилищник.docx"
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибка:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Не указаны обязательные параметры: module, recordId, fileName, documentText"
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 n8n Expression для Body
|
||||
|
||||
Если используете выражения n8n в Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "{{ $json.body.context.module || 'Project' }}",
|
||||
"recordId": "{{ $json.body.context.projectId }}",
|
||||
"recordName": "{{ $json.body.context.projectName || 'Проект' }}",
|
||||
"fileName": "{{ $json.body.documentName || 'Документ_' + Date.now() }}",
|
||||
"documentText": "{{ $json.body.generatedText }}",
|
||||
"documentType": "{{ $json.body.documentType || 'docx' }}"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Быстрый тест
|
||||
|
||||
```bash
|
||||
# Минимальный тест
|
||||
curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_text.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "Project",
|
||||
"recordId": "123456",
|
||||
"recordName": "Тест",
|
||||
"fileName": "Тест",
|
||||
"documentText": "Тестовый документ\n\nЭто тест создания документа через API."
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
219
crm_extensions/file_storage/docs/NEXTCLOUD_TEMPLATES.md
Normal file
219
crm_extensions/file_storage/docs/NEXTCLOUD_TEMPLATES.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 📋 Настройка шаблонов документов в Nextcloud
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Готово к использованию
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Для генерации документов из шаблонов используется гибридный подход:
|
||||
1. **Шаблоны хранятся в Nextcloud** в папке `/crm/Templates/`
|
||||
2. **Заполнение переменных** происходит через PHPWord
|
||||
3. **Готовый документ** сохраняется в папку проекта и открывается в OnlyOffice
|
||||
|
||||
## 📁 Структура шаблонов
|
||||
|
||||
### 1. Создание папки для шаблонов
|
||||
|
||||
В Nextcloud создайте папку:
|
||||
```
|
||||
/crm/Templates/
|
||||
```
|
||||
|
||||
**Как создать:**
|
||||
1. Зайдите в Nextcloud: `https://office.clientright.ru:8443`
|
||||
2. Перейдите в папку `/crm/`
|
||||
3. Создайте папку `Templates`
|
||||
4. Загрузите туда типовые документы
|
||||
|
||||
### 2. Формат шаблонов
|
||||
|
||||
#### Формат переменных в шаблонах:
|
||||
|
||||
**Вариант 1: Простые переменные**
|
||||
```
|
||||
{CLIENT_NAME}
|
||||
{DATE}
|
||||
{AMOUNT}
|
||||
```
|
||||
|
||||
**Вариант 2: Двойные фигурные скобки**
|
||||
```
|
||||
{{CLIENT_NAME}}
|
||||
{{DATE}}
|
||||
{{AMOUNT}}
|
||||
```
|
||||
|
||||
#### Пример шаблона претензии:
|
||||
|
||||
```docx
|
||||
ПРЕТЕНЗИЯ
|
||||
|
||||
Кому: {RESPONDENT_NAME}
|
||||
От: {CLIENT_NAME}
|
||||
|
||||
Дата: {DATE}
|
||||
|
||||
Текст претензии:
|
||||
{CLAIM_TEXT}
|
||||
|
||||
Требования:
|
||||
1. Возместить ущерб в размере {AMOUNT} рублей
|
||||
2. {OTHER_REQUIREMENTS}
|
||||
|
||||
С уважением,
|
||||
{CLIENT_NAME}
|
||||
```
|
||||
|
||||
## 🔧 Использование API
|
||||
|
||||
### Endpoint: `/crm_extensions/file_storage/api/create_from_template.php`
|
||||
|
||||
**Параметры:**
|
||||
- `module` - модуль CRM (Project, Contacts, etc.)
|
||||
- `recordId` - ID записи
|
||||
- `recordName` - название записи
|
||||
- `fileName` - имя создаваемого файла
|
||||
- `templateName` - имя шаблона из Nextcloud (например, `pretenziya.docx`)
|
||||
- `variables` - JSON объект с переменными для заполнения
|
||||
|
||||
**Пример запроса:**
|
||||
```javascript
|
||||
const url = `/crm_extensions/file_storage/api/create_from_template.php?` +
|
||||
`module=Project&` +
|
||||
`recordId=123456&` +
|
||||
`recordName=Проект_1&` +
|
||||
`fileName=Претензия_УК&` +
|
||||
`templateName=pretenziya.docx&` +
|
||||
`variables=${encodeURIComponent(JSON.stringify({
|
||||
CLIENT_NAME: 'Иванов Иван Иванович',
|
||||
DATE: '15.01.2025',
|
||||
AMOUNT: '400000',
|
||||
RESPONDENT_NAME: 'УК "Жилищник"',
|
||||
CLAIM_TEXT: 'УК отказывается возмещать ущерб от залива квартиры...',
|
||||
OTHER_REQUIREMENTS: 'Провести экспертизу'
|
||||
}))}`;
|
||||
|
||||
window.location.href = url;
|
||||
```
|
||||
|
||||
## 🚀 Интеграция с AI Drawer
|
||||
|
||||
### Пример использования в n8n:
|
||||
|
||||
```javascript
|
||||
// После генерации текста AI
|
||||
const aiResponse = {
|
||||
document_type: 'pretenziya',
|
||||
client_name: 'Иванов Иван Иванович',
|
||||
amount: '400000',
|
||||
claim_text: '...',
|
||||
// ... другие данные
|
||||
};
|
||||
|
||||
// Определяем шаблон по типу документа
|
||||
const templateMap = {
|
||||
'pretenziya': 'pretenziya.docx',
|
||||
'isk': 'iskovoe_zayavlenie.docx',
|
||||
'zhaloba': 'zhaloba.docx',
|
||||
'hodataystvo': 'hodataystvo.docx'
|
||||
};
|
||||
|
||||
const templateName = templateMap[aiResponse.document_type] || 'pretenziya.docx';
|
||||
|
||||
// Формируем переменные
|
||||
const variables = {
|
||||
CLIENT_NAME: aiResponse.client_name,
|
||||
DATE: new Date().toLocaleDateString('ru-RU'),
|
||||
AMOUNT: aiResponse.amount,
|
||||
CLAIM_TEXT: aiResponse.claim_text,
|
||||
// ... другие переменные
|
||||
};
|
||||
|
||||
// Вызываем API создания документа
|
||||
const createUrl = `https://crm.clientright.ru/crm_extensions/file_storage/api/create_from_template.php?` +
|
||||
`module=Project&` +
|
||||
`recordId=${projectId}&` +
|
||||
`recordName=${projectName}&` +
|
||||
`fileName=${fileName}&` +
|
||||
`templateName=${templateName}&` +
|
||||
`variables=${encodeURIComponent(JSON.stringify(variables))}`;
|
||||
|
||||
// Открываем документ
|
||||
return { url: createUrl };
|
||||
```
|
||||
|
||||
## 📝 Создание шаблонов
|
||||
|
||||
### Рекомендации по созданию шаблонов:
|
||||
|
||||
1. **Используйте стандартные названия:**
|
||||
- `pretenziya.docx` - Претензия
|
||||
- `iskovoe_zayavlenie.docx` - Исковое заявление
|
||||
- `zhaloba.docx` - Жалоба
|
||||
- `hodataystvo.docx` - Ходатайство
|
||||
|
||||
2. **Структура документа:**
|
||||
- Шапка (кому, от кого, дата)
|
||||
- Фабула (описание ситуации)
|
||||
- Требования
|
||||
- Ссылки на право
|
||||
- Приложения
|
||||
|
||||
3. **Переменные:**
|
||||
- Используйте понятные названия: `CLIENT_NAME`, `AMOUNT`, `DATE`
|
||||
- Все переменные в верхнем регистре
|
||||
- Обрамляйте фигурными скобками: `{VAR}` или `{{VAR}}`
|
||||
|
||||
## 🔍 Отладка
|
||||
|
||||
### Логи:
|
||||
```bash
|
||||
tail -f /var/log/apache2/error.log | grep "CREATE FROM TEMPLATE"
|
||||
```
|
||||
|
||||
### Проверка шаблона:
|
||||
```bash
|
||||
# Проверить наличие шаблона в Nextcloud
|
||||
curl -u admin:office "https://office.clientright.ru:8443/remote.php/dav/files/admin/crm/Templates/pretenziya.docx" -k -I
|
||||
```
|
||||
|
||||
## ⚠️ Ограничения
|
||||
|
||||
1. **PHPWord** работает только с DOCX файлами
|
||||
2. Для XLSX и PPTX используется простая замена текста
|
||||
3. Сложное форматирование (таблицы, изображения) может не сохраниться при простой замене
|
||||
|
||||
## 🎯 Альтернативные подходы
|
||||
|
||||
### Вариант A: Использование DOCX шаблонов с закладками
|
||||
|
||||
Вместо переменных `{VAR}` можно использовать закладки Word:
|
||||
1. В Word: Вставка → Закладка
|
||||
2. Создать закладку с именем переменной
|
||||
3. PHPWord может заполнять закладки
|
||||
|
||||
### Вариант B: Использование только локальных шаблонов
|
||||
|
||||
Если не нужна синхронизация через Nextcloud:
|
||||
1. Хранить шаблоны в `/crm_extensions/file_storage/templates/`
|
||||
2. Использовать напрямую без WebDAV
|
||||
|
||||
### Вариант C: Генерация через PDFMaker
|
||||
|
||||
Если документ должен быть в PDF:
|
||||
1. Создать DOCX из шаблона
|
||||
2. Конвертировать через PDFMaker
|
||||
3. Сохранить PDF в проект
|
||||
|
||||
## 📚 Полезные ссылки
|
||||
|
||||
- [PHPWord Documentation](https://phpword.readthedocs.io/)
|
||||
- [Nextcloud WebDAV API](https://docs.nextcloud.com/server/latest/user_manual/files/webdav.html)
|
||||
- [OnlyOffice Integration](https://api.onlyoffice.com/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# 🔍 Анализ проблемы с API шаблонов Nextcloud
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Проблема найдена и решена
|
||||
|
||||
## 🎯 Проблема
|
||||
|
||||
При попытке получить список шаблонов через API:
|
||||
```bash
|
||||
curl "https://office.clientright.ru:8443/ocs/v2.php/apps/files/api/v1/directEditing/templates"
|
||||
```
|
||||
|
||||
Получаем ошибку:
|
||||
```xml
|
||||
<status>failure</status>
|
||||
<statuscode>998</statuscode>
|
||||
<message>Invalid query, please check the syntax.</message>
|
||||
```
|
||||
|
||||
## 🔬 Диагностика
|
||||
|
||||
### 1. Проверка основного API Direct Editing
|
||||
|
||||
**Запрос:**
|
||||
```bash
|
||||
curl -u admin:office "https://office.clientright.ru:8443/ocs/v2.php/apps/files/api/v1/directEditing" \
|
||||
-H "OCS-APIRequest: true" -k
|
||||
```
|
||||
|
||||
**Результат:** ✅ API работает, возвращает список редакторов и создателей
|
||||
|
||||
**Структура ответа:**
|
||||
```xml
|
||||
<editors>
|
||||
<onlyoffice>
|
||||
<id>onlyoffice</id>
|
||||
<name>ONLYOFFICE</name>
|
||||
<mimetypes>...</mimetypes>
|
||||
<optionalMimetypes>...</optionalMimetypes>
|
||||
</onlyoffice>
|
||||
</editors>
|
||||
<creators>
|
||||
<onlyoffice_docx>
|
||||
<id>onlyoffice_docx</id>
|
||||
<editor>onlyoffice</editor>
|
||||
<name>Новый документ</name>
|
||||
<extension>docx</extension>
|
||||
<templates></templates> ← ПУСТОЙ!
|
||||
<mimetype>application/vnd.openxmlformats-officedocument.wordprocessingml.document</mimetype>
|
||||
</onlyoffice_docx>
|
||||
</creators>
|
||||
```
|
||||
|
||||
### 2. Выводы
|
||||
|
||||
**Проблема №1: Endpoint `/templates` не существует**
|
||||
- Nextcloud Direct Editing API не имеет отдельного endpoint `/templates`
|
||||
- Шаблоны должны возвращаться внутри основного ответа `/directEditing`
|
||||
- Тег `<templates></templates>` присутствует, но **пустой**
|
||||
|
||||
**Проблема №2: Шаблоны не настроены в Nextcloud**
|
||||
- Тег `<templates></templates>` пустой означает, что шаблоны не найдены
|
||||
- Nextcloud ищет шаблоны в специальной папке, но она либо не существует, либо пустая
|
||||
|
||||
## 📚 Как работает система шаблонов в Nextcloud
|
||||
|
||||
### Где Nextcloud ищет шаблоны:
|
||||
|
||||
1. **Системные шаблоны OnlyOffice:**
|
||||
- Хранятся в конфигурации OnlyOffice
|
||||
- Недоступны через Nextcloud API напрямую
|
||||
|
||||
2. **Пользовательские шаблоны:**
|
||||
- Должны быть в специальной папке пользователя
|
||||
- Путь зависит от конфигурации Nextcloud
|
||||
- Обычно: `/Templates/` в корне пользователя
|
||||
|
||||
3. **Глобальные шаблоны:**
|
||||
- Могут быть настроены администратором
|
||||
- Путь настраивается в `config.php`
|
||||
|
||||
## ✅ Решение
|
||||
|
||||
### Вариант 1: Использовать WebDAV для получения списка шаблонов (РЕКОМЕНДУЕТСЯ)
|
||||
|
||||
Вместо несуществующего API endpoint, используем WebDAV PROPFIND:
|
||||
|
||||
```php
|
||||
// Получаем список файлов из папки Templates
|
||||
$templatesUrl = 'https://office.clientright.ru:8443/remote.php/dav/files/admin/crm/Templates/';
|
||||
|
||||
$ch = curl_init($templatesUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Depth: 1']);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "admin:office");
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// Парсим XML ответ и извлекаем имена файлов
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Работает всегда (WebDAV - стандартный протокол)
|
||||
- ✅ Не зависит от версии Nextcloud
|
||||
- ✅ Можно получить метаданные файлов (размер, дата изменения)
|
||||
|
||||
### Вариант 2: Настроить шаблоны в Nextcloud (если нужно)
|
||||
|
||||
Если хотите использовать встроенную систему шаблонов:
|
||||
|
||||
1. **Создать папку Templates:**
|
||||
```
|
||||
/admin/Templates/ (в корне пользователя admin)
|
||||
```
|
||||
|
||||
2. **Загрузить шаблоны:**
|
||||
- Загрузить DOCX файлы через веб-интерфейс
|
||||
- Или через WebDAV
|
||||
|
||||
3. **Проверить конфигурацию:**
|
||||
```php
|
||||
// config/config.php
|
||||
'direct_editing' => [
|
||||
'templates' => [
|
||||
'path' => '/admin/Templates/',
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
**Проблема:** Не все версии Nextcloud поддерживают это из коробки.
|
||||
|
||||
### Вариант 3: Использовать наш подход (ТЕКУЩИЙ)
|
||||
|
||||
Мы уже реализовали решение через WebDAV:
|
||||
- Шаблоны хранятся в `/crm/Templates/`
|
||||
- Получаем список через WebDAV PROPFIND
|
||||
- Скачиваем шаблон через WebDAV GET
|
||||
- Заполняем переменные через PHPWord
|
||||
- Сохраняем готовый документ
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Работает независимо от версии Nextcloud
|
||||
- ✅ Полный контроль над процессом
|
||||
- ✅ Можно использовать сложную логику заполнения
|
||||
|
||||
## 🔧 Реализация получения списка шаблонов
|
||||
|
||||
Создадим endpoint для получения списка шаблонов:
|
||||
|
||||
```php
|
||||
// /crm_extensions/file_storage/api/list_templates.php
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/shared/EnvLoader.php';
|
||||
EnvLoader::load('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/.env');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$nextcloudUrl = 'https://office.clientright.ru:8443';
|
||||
$username = 'admin';
|
||||
$password = 'office';
|
||||
$templatesPath = '/crm/Templates/';
|
||||
|
||||
$webdavUrl = $nextcloudUrl . '/remote.php/dav/files/' . $username . $templatesPath;
|
||||
|
||||
$ch = curl_init($webdavUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Depth: 1',
|
||||
'Content-Type: application/xml'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/></d:prop></d:propfind>');
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 207) { // 207 Multi-Status для PROPFIND
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to get templates']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Парсим XML
|
||||
$xml = simplexml_load_string($response);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
|
||||
$templates = [];
|
||||
foreach ($xml->xpath('//d:response') as $response) {
|
||||
$href = (string)$response->xpath('.//d:href')[0];
|
||||
$displayName = (string)$response->xpath('.//d:displayname')[0];
|
||||
$contentType = (string)$response->xpath('.//d:getcontenttype')[0];
|
||||
|
||||
// Пропускаем саму папку
|
||||
if (rtrim($href, '/') === rtrim($webdavUrl, '/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Только Office файлы
|
||||
if (strpos($contentType, 'officedocument') !== false ||
|
||||
strpos($contentType, 'msword') !== false ||
|
||||
strpos($contentType, 'spreadsheet') !== false ||
|
||||
strpos($contentType, 'presentation') !== false) {
|
||||
|
||||
$templates[] = [
|
||||
'name' => $displayName,
|
||||
'path' => $href,
|
||||
'type' => $contentType
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||
```
|
||||
|
||||
## 📊 Сравнение подходов
|
||||
|
||||
| Подход | Работает | Сложность | Гибкость |
|
||||
|--------|----------|-----------|----------|
|
||||
| API `/templates` | ❌ Не существует | - | - |
|
||||
| WebDAV PROPFIND | ✅ Да | Средняя | Высокая |
|
||||
| Настройка Nextcloud | ⚠️ Зависит от версии | Высокая | Низкая |
|
||||
| Наш подход (WebDAV + PHPWord) | ✅ Да | Средняя | Очень высокая |
|
||||
|
||||
## 🎯 Рекомендация
|
||||
|
||||
**Использовать WebDAV PROPFIND** для получения списка шаблонов:
|
||||
- ✅ Надежно работает
|
||||
- ✅ Не зависит от версии Nextcloud
|
||||
- ✅ Можно получить метаданные
|
||||
- ✅ Стандартный протокол
|
||||
|
||||
**Не использовать** несуществующий endpoint `/templates`:
|
||||
- ❌ Его нет в API Nextcloud
|
||||
- ❌ Возвращает ошибку 998 (Invalid query)
|
||||
|
||||
## 📝 Выводы
|
||||
|
||||
1. **Проблема:** Endpoint `/ocs/v2.php/apps/files/api/v1/directEditing/templates` не существует в Nextcloud API
|
||||
2. **Причина:** Nextcloud не предоставляет отдельный endpoint для получения шаблонов
|
||||
3. **Решение:** Использовать WebDAV PROPFIND для получения списка файлов из папки Templates
|
||||
4. **Статус:** Наш текущий подход (WebDAV + PHPWord) является правильным и оптимальным решением
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 🔍 Анализ системы шаблонов ONLYOFFICE в Nextcloud
|
||||
|
||||
**Дата:** 2025-01-XX
|
||||
**Статус:** ✅ Найдено решение
|
||||
|
||||
## 🎯 Проблема
|
||||
|
||||
В настройках ONLYOFFICE видно раздел "Общие шаблоны" с шаблоном:
|
||||
- `Соглашение_№_71_06_об_оказании_юридической_помощи_от_01_10_2025_года.docx`
|
||||
|
||||
Но при попытке получить список через API Nextcloud Direct Editing - шаблоны не возвращаются.
|
||||
|
||||
## 🔬 Диагностика
|
||||
|
||||
### 1. Проверка папки Templates
|
||||
|
||||
**Найдено:** Папка `/Templates/` существует в корне пользователя `admin`
|
||||
|
||||
**Содержимое:**
|
||||
- Стандартные шаблоны Nextcloud (ODT, ODS, ODP)
|
||||
- Различные типы документов (Letter, Invoice, Resume и т.д.)
|
||||
|
||||
**WebDAV путь:**
|
||||
```
|
||||
https://office.clientright.ru:8443/remote.php/dav/files/admin/Templates/
|
||||
```
|
||||
|
||||
### 2. Структура шаблонов ONLYOFFICE
|
||||
|
||||
ONLYOFFICE использует **два типа шаблонов**:
|
||||
|
||||
1. **Стандартные шаблоны Nextcloud** (`/Templates/`)
|
||||
- Доступны через WebDAV
|
||||
- Форматы: ODT, ODS, ODP
|
||||
- Стандартные шаблоны из коробки
|
||||
|
||||
2. **Общие шаблоны ONLYOFFICE** (General Templates)
|
||||
- Хранятся в специальной системе ONLYOFFICE
|
||||
- Могут быть в формате DOCX, XLSX, PPTX
|
||||
- Управляются через интерфейс настроек ONLYOFFICE
|
||||
- **Могут храниться в базе данных или специальной папке**
|
||||
|
||||
### 3. Где хранятся "Общие шаблоны" ONLYOFFICE?
|
||||
|
||||
**Варианты хранения:**
|
||||
|
||||
**Вариант A: В базе данных Nextcloud**
|
||||
- ONLYOFFICE может хранить метаданные шаблонов в БД
|
||||
- Файлы могут быть в специальной папке приложения
|
||||
|
||||
**Вариант B: В папке приложения ONLYOFFICE**
|
||||
- Возможно: `/apps/onlyoffice/templates/`
|
||||
- Или: `/data/admin/files/Templates/` (но это обычная папка)
|
||||
|
||||
**Вариант C: В специальной папке ONLYOFFICE**
|
||||
- Может быть скрытая папка или папка с особыми правами
|
||||
- Возможно, в корне пользователя, но с особым флагом
|
||||
|
||||
## ✅ Решение
|
||||
|
||||
### Подход 1: Использовать WebDAV для получения всех шаблонов
|
||||
|
||||
**Текущее решение работает:**
|
||||
- Скрипт `list_templates.php` получает список файлов из `/Templates/`
|
||||
- Можно использовать для стандартных шаблонов
|
||||
|
||||
**Ограничение:**
|
||||
- Не получает "Общие шаблоны" ONLYOFFICE, если они хранятся отдельно
|
||||
|
||||
### Подход 2: Добавить шаблоны в папку Templates
|
||||
|
||||
**Рекомендация:**
|
||||
1. Скачать шаблон "Соглашение..." из настроек ONLYOFFICE
|
||||
2. Загрузить его в папку `/Templates/` через WebDAV или веб-интерфейс
|
||||
3. Теперь он будет доступен через наш API
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Единая точка доступа ко всем шаблонам
|
||||
- ✅ Работает через WebDAV (стандартный протокол)
|
||||
- ✅ Не зависит от внутренней структуры ONLYOFFICE
|
||||
|
||||
### Подход 3: Использовать API ONLYOFFICE напрямую (если доступен)
|
||||
|
||||
**Проверка:**
|
||||
```bash
|
||||
# Попытка получить шаблоны через ONLYOFFICE API
|
||||
curl "https://office.clientright.ru:8443/index.php/apps/onlyoffice/ajax/templates"
|
||||
```
|
||||
|
||||
**Статус:** Не работает (возвращает 404)
|
||||
|
||||
## 📝 Рекомендации
|
||||
|
||||
### Для использования шаблонов:
|
||||
|
||||
1. **Создать папку `/crm/Templates/` для наших шаблонов:**
|
||||
- Хранить типовые документы (претензии, иски, жалобы)
|
||||
- Использовать формат DOCX с переменными `{VAR_NAME}`
|
||||
|
||||
2. **Использовать существующую папку `/Templates/`:**
|
||||
- Добавить туда наши шаблоны
|
||||
- Использовать наш API для получения списка
|
||||
|
||||
3. **Для "Общих шаблонов" ONLYOFFICE:**
|
||||
- Экспортировать их из настроек ONLYOFFICE
|
||||
- Загрузить в папку `/Templates/` или `/crm/Templates/`
|
||||
- Использовать через наш API
|
||||
|
||||
## 🔧 Обновленные скрипты
|
||||
|
||||
### `list_templates.php`
|
||||
- ✅ Исправлен путь на `/Templates/` (корень пользователя)
|
||||
- ✅ Работает с WebDAV PROPFIND
|
||||
- ✅ Возвращает список всех Office файлов
|
||||
|
||||
### `create_from_template.php`
|
||||
- ✅ Исправлен путь на `/Templates/{templateName}`
|
||||
- ✅ Скачивает шаблон через WebDAV
|
||||
- ✅ Заполняет переменные через PHPWord
|
||||
- ✅ Сохраняет готовый документ
|
||||
|
||||
## 🎯 Выводы
|
||||
|
||||
1. **Шаблоны ONLYOFFICE хранятся в папке `/Templates/`** в корне пользователя
|
||||
2. **"Общие шаблоны" ONLYOFFICE** могут быть в той же папке или в специальной системе
|
||||
3. **Наш подход через WebDAV работает** для всех шаблонов в папке `/Templates/`
|
||||
4. **Рекомендуется:** Добавить наши шаблоны в `/Templates/` или создать `/crm/Templates/` для наших документов
|
||||
|
||||
## 📚 Следующие шаги
|
||||
|
||||
1. Проверить, есть ли шаблон "Соглашение..." в папке `/Templates/`
|
||||
2. Если нет - экспортировать из настроек ONLYOFFICE и загрузить в папку
|
||||
3. Протестировать получение списка через `list_templates.php`
|
||||
4. Использовать шаблоны через `create_from_template.php`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
92
crm_extensions/file_storage/fix_document_397340_path.php
Normal file
92
crm_extensions/file_storage/fix_document_397340_path.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Исправление пути документа 397340 в проекте 396447
|
||||
*
|
||||
* Проблема: документ 397340 имеет путь с префиксом 'crm2/CRM_Active_Files/',
|
||||
* а остальные документы проекта имеют путь БЕЗ этого префикса.
|
||||
*
|
||||
* Решение: убрать префикс 'crm2/CRM_Active_Files/' из s3_key для единообразия.
|
||||
*/
|
||||
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8mb4",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$notesId = 397340;
|
||||
$dryRun = false; // Изменить на false для реального исправления
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ ПУТИ ДОКУМЕНТА 397340 ===\n\n";
|
||||
|
||||
// Получаем текущие данные документа
|
||||
$sql = "SELECT notesid, title, s3_key, s3_bucket, filename FROM vtiger_notes WHERE notesid = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$notesId]);
|
||||
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$doc) {
|
||||
die("❌ Документ $notesId не найден!\n");
|
||||
}
|
||||
|
||||
echo "📄 Документ: {$doc['title']}\n";
|
||||
echo " ID: {$doc['notesid']}\n";
|
||||
echo " Текущий s3_key: {$doc['s3_key']}\n\n";
|
||||
|
||||
// Проверяем, есть ли префикс
|
||||
if (strpos($doc['s3_key'], 'crm2/CRM_Active_Files/') === 0) {
|
||||
// Убираем префикс
|
||||
$newS3Key = str_replace('crm2/CRM_Active_Files/', '', $doc['s3_key']);
|
||||
|
||||
echo "✅ Найден префикс 'crm2/CRM_Active_Files/'\n";
|
||||
echo " Новый s3_key: $newS3Key\n\n";
|
||||
|
||||
// Проверяем остальные документы проекта для сравнения
|
||||
$sql2 = "SELECT notesid, s3_key FROM vtiger_notes n
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = 396447 AND n.notesid != ? AND n.s3_key IS NOT NULL
|
||||
LIMIT 3";
|
||||
$stmt2 = $pdo->prepare($sql2);
|
||||
$stmt2->execute([$notesId]);
|
||||
$others = $stmt2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "📊 Сравнение с другими документами проекта:\n";
|
||||
foreach ($others as $other) {
|
||||
echo " ID {$other['notesid']}: {$other['s3_key']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// Обновляем filename тоже (если там есть полный URL)
|
||||
$newFilename = $doc['filename'];
|
||||
if (strpos($doc['filename'], 'crm2/CRM_Active_Files/') !== false) {
|
||||
$newFilename = str_replace('crm2/CRM_Active_Files/', '', $doc['filename']);
|
||||
// Если это полный URL, пересобираем его
|
||||
if (strpos($newFilename, 'https://') === false && $doc['s3_bucket']) {
|
||||
$newFilename = "https://s3.twcstorage.ru/{$doc['s3_bucket']}/" . rawurlencode($newS3Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
echo "🔧 ПРИМЕНЯЕМ ИСПРАВЛЕНИЕ...\n\n";
|
||||
|
||||
$updateSql = "UPDATE vtiger_notes SET s3_key = ?, filename = ? WHERE notesid = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute([$newS3Key, $newFilename, $notesId]);
|
||||
|
||||
echo "✅ Документ обновлён!\n";
|
||||
echo " Новый s3_key: $newS3Key\n";
|
||||
echo " Новый filename: " . substr($newFilename, 0, 100) . "...\n";
|
||||
} else {
|
||||
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run)\n";
|
||||
echo " Для применения изменений установите \$dryRun = false\n";
|
||||
}
|
||||
} else {
|
||||
echo "ℹ️ Префикс 'crm2/CRM_Active_Files/' не найден в пути.\n";
|
||||
echo " Документ уже в правильном формате.\n";
|
||||
}
|
||||
|
||||
echo "\n=== ГОТОВО ===\n";
|
||||
|
||||
@@ -23,8 +23,8 @@ const CONFIG = {
|
||||
},
|
||||
// Индексируем только файлы из этих папок
|
||||
pathPrefixes: [
|
||||
'files/crm/crm2/',
|
||||
'files/crm/erv_app/'
|
||||
'crm2/CRM_Active_Files/', // ИСПРАВЛЕНО: без 'files/' префикса!
|
||||
'erv_app/'
|
||||
],
|
||||
indexInterval: 60000 // Обновляем индекс каждую минуту
|
||||
};
|
||||
|
||||
Binary file not shown.
38
crm_extensions/file_storage/verify_prefix_396447.php
Normal file
38
crm_extensions/file_storage/verify_prefix_396447.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8mb4",
|
||||
$dbconfig['db_username'],
|
||||
$dbconfig['db_password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
echo "=== ПРОВЕРКА РЕЗУЛЬТАТА ===\n\n";
|
||||
|
||||
$sql = "SELECT notesid, s3_key FROM vtiger_notes n
|
||||
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
|
||||
WHERE snr.crmid = 396447 AND n.s3_key IS NOT NULL
|
||||
ORDER BY notesid";
|
||||
$stmt = $pdo->query($sql);
|
||||
$docs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$allCorrect = true;
|
||||
foreach ($docs as $doc) {
|
||||
$hasPrefix = strpos($doc['s3_key'], 'crm2/CRM_Active_Files') === 0;
|
||||
$status = $hasPrefix ? '✅' : '❌';
|
||||
$pathStart = substr($doc['s3_key'], 0, 60);
|
||||
echo sprintf("%s ID %-8s: %s...\n", $status, $doc['notesid'], $pathStart);
|
||||
if (!$hasPrefix) {
|
||||
$allCorrect = false;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
if ($allCorrect) {
|
||||
echo "✅ ВСЕ ДОКУМЕНТЫ ИМЕЮТ ПРЕФИКС 'crm2/CRM_Active_Files/'!\n";
|
||||
echo " Всего документов: " . count($docs) . "\n";
|
||||
} else {
|
||||
echo "⚠️ ЕСТЬ ДОКУМЕНТЫ БЕЗ ПРЕФИКСА\n";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
|
||||
projectName = projectName.replace(/"/g, '_');
|
||||
// Заменяем ВСЕ пробелы на подчёркивания
|
||||
projectName = projectName.replace(/\s+/g, '_');
|
||||
// Заменяем множественные подчёркивания на одинарное
|
||||
projectName = projectName.replace(/_+/g, '_');
|
||||
// Убираем подчёркивания в начале и конце
|
||||
projectName = projectName.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
// Формируем URL для папки проекта в Nextcloud
|
||||
@@ -446,6 +450,95 @@ function editInNextcloud(recordId, fileName) {
|
||||
return openNextcloudEditor(recordId, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Открытие файла через Nextcloud Files UI (с версионированием)
|
||||
* Использует Redis индекс для быстрого получения FileID
|
||||
*/
|
||||
function openViaNextcloud(recordId, fileName) {
|
||||
console.log('📚 Opening via Nextcloud Files UI:', recordId);
|
||||
|
||||
// Получаем FileID и redirect URL из API
|
||||
fetch(`/crm_extensions/file_storage/api/nextcloud_open.php?recordId=${recordId}&v=${Date.now()}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('✅ FileID получен:', data.fileId);
|
||||
console.log('🔗 Redirect URL:', data.redirectUrl);
|
||||
|
||||
// Открываем Nextcloud в новом окне
|
||||
const win = window.open(data.redirectUrl, 'nextcloud_files_' + Date.now(), 'width=1400,height=900,scrollbars=yes,resizable=yes');
|
||||
|
||||
if (win) {
|
||||
console.log('✅ Nextcloud opened successfully');
|
||||
} else {
|
||||
console.log('❌ Failed to open window - popup blocked');
|
||||
alert('❌ Не удалось открыть Nextcloud. Проверьте блокировку всплывающих окон.');
|
||||
}
|
||||
} else {
|
||||
console.error('❌ API error:', data);
|
||||
alert('❌ Ошибка получения FileID: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Fetch error:', error);
|
||||
alert('❌ Ошибка подключения к API');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание нового файла в Nextcloud
|
||||
* @param {string} module - Модуль CRM (Project, Contacts, Accounts и т.д.)
|
||||
* @param {string} recordId - ID записи
|
||||
* @param {string} recordName - Название записи (для имени папки)
|
||||
* @param {string} fileType - Тип файла (docx, xlsx, pptx)
|
||||
*/
|
||||
function createFileInNextcloud(module, recordId, recordName, fileType) {
|
||||
console.log('🆕 Creating file in Nextcloud:', { module, recordId, recordName, fileType });
|
||||
|
||||
// Формируем имя файла по умолчанию
|
||||
const fileTypeNames = {
|
||||
'docx': 'Документ',
|
||||
'xlsx': 'Таблица',
|
||||
'pptx': 'Презентация'
|
||||
};
|
||||
|
||||
const defaultName = `${fileTypeNames[fileType]}_${new Date().toISOString().split('T')[0]}`;
|
||||
|
||||
// Запрашиваем имя файла у пользователя
|
||||
const fileName = prompt(`Введите название файла (без расширения):`, defaultName);
|
||||
|
||||
if (!fileName) {
|
||||
console.log('❌ File creation cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем прогресс
|
||||
if (typeof app !== 'undefined' && app.helper && app.helper.showProgress) {
|
||||
app.helper.showProgress({
|
||||
message: 'Создание файла в Nextcloud...'
|
||||
});
|
||||
}
|
||||
|
||||
// Вызываем скрипт создания
|
||||
const createUrl = `/crm_extensions/file_storage/api/create_nextcloud_file.php?module=${module}&recordId=${recordId}&recordName=${encodeURIComponent(recordName)}&fileName=${encodeURIComponent(fileName)}&fileType=${fileType}`;
|
||||
|
||||
console.log('🎯 Creating file:', createUrl);
|
||||
|
||||
// Открываем в новом окне (скрипт создаст файл и откроет редактор)
|
||||
const win = window.open(createUrl, 'nextcloud_create_' + Date.now(), 'width=1400,height=900,scrollbars=yes,resizable=yes');
|
||||
|
||||
// Скрываем прогресс
|
||||
setTimeout(function() {
|
||||
if (typeof app !== 'undefined' && app.helper && app.helper.hideProgress) {
|
||||
app.helper.hideProgress();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
if (!win) {
|
||||
alert('❌ Не удалось открыть Nextcloud. Проверьте блокировку всплывающих окон.');
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическое подключение при загрузке страницы
|
||||
$(document).ready(function() {
|
||||
console.log('Nextcloud Editor integration loaded');
|
||||
|
||||
124
docs/BROWSERLESS_EJ_SUDRF_ESIA_LOGIN.md
Normal file
124
docs/BROWSERLESS_EJ_SUDRF_ESIA_LOGIN.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Авторизация ej.sudrf.ru через ЕСИА (n8n + Browserless)
|
||||
|
||||
Скрипт `browserless_login_esia.js` выполняет в headless-браузере (Browserless) авторизацию на портале ГАС «Правосудие» через ЕСИА (Госуслуги) и доводит процесс до экрана ввода SMS-кода.
|
||||
|
||||
## Назначение
|
||||
|
||||
- Используется в n8n workflow: нода **HTTP Request** дергает Browserless по адресу вида
|
||||
`http://<browserless-host>:3000/function?token=...&timeout=180000`.
|
||||
- Вход: логин (телефон/почта/СНИЛС) и пароль от ЕСИА.
|
||||
- Выход: статус `waiting_for_sms`, куки сессии и скриншот — для следующего шага (ввод SMS и получение финальных кук ej.sudrf.ru).
|
||||
|
||||
## Требования
|
||||
|
||||
- **Browserless** (Puppeteer): endpoint `/function` с передачей кода и опционально `context`/полей в body.
|
||||
- **n8n**: предыдущая нода (например, Set) передаёт `login` и `pass` (или они задаются в body запроса к Browserless).
|
||||
|
||||
## Шаг 1: До экрана SMS (этот скрипт)
|
||||
|
||||
### Что делает скрипт
|
||||
|
||||
1. Открывает `https://ej.sudrf.ru/?fromOa=16RS0018`.
|
||||
2. Если нужно — кликает «Вход» и переходит на страницу «Авторизация пользователя».
|
||||
3. Ставит галочку согласия (`#iAgree`), ждёт активации кнопки «Войти», нажимает её.
|
||||
4. На ЕСИА заполняет логин и пароль (видимые поля, ввод через клавиатуру + blur/change).
|
||||
5. Нажимает «Войти» на ЕСИА.
|
||||
6. Ждёт появления полей для ввода SMS-кода (или перехода на другую страницу).
|
||||
7. Возвращает куки через `page.cookies()` и скриншот.
|
||||
|
||||
### Входные данные
|
||||
|
||||
Скрипт принимает второй аргумент `input` (объект из body запроса к Browserless):
|
||||
|
||||
| Поле | Описание |
|
||||
|------------|------------------------------------|
|
||||
| `login` | Телефон / эл. почта / СНИЛС (ЕСИА) |
|
||||
| `pass` или `password` | Пароль ЕСИА |
|
||||
|
||||
Либо те же поля внутри `input.context` (например `context.login`, `context.pass`).
|
||||
|
||||
В n8n в **Body** запроса к Browserless можно передать:
|
||||
|
||||
- Отдельные поля (рекомендуется, без спецсимволов в коде):
|
||||
```json
|
||||
{
|
||||
"code": "<содержимое browserless_login_esia.js>",
|
||||
"login": "={{ $json.login }}",
|
||||
"pass": "={{ $json.pass }}"
|
||||
}
|
||||
```
|
||||
- Либо один объект `context`:
|
||||
```json
|
||||
{
|
||||
"code": "...",
|
||||
"context": {
|
||||
"login": "={{ $json.login }}",
|
||||
"pass": "={{ $json.pass }}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пароль может содержать спецсимволы (например `!`); передавать его отдельным полем в body предпочтительно, чтобы не ломать разбор кода.
|
||||
|
||||
### Выходные данные
|
||||
|
||||
При успехе:
|
||||
|
||||
- `status`: `"waiting_for_sms"`
|
||||
- `url`: текущий URL (страница ЕСИА с полями SMS).
|
||||
- `cookies`: массив кук (для передачи во второй скрипт или сохранения).
|
||||
- `screenshot`: base64 скриншот страницы.
|
||||
- `sms_inputs_count`: число полей для ввода кода.
|
||||
- `session_data`: заметка по использованию кук.
|
||||
|
||||
При ошибке:
|
||||
|
||||
- `status`: `"error"`
|
||||
- `error_type`: тип ошибки (см. ниже).
|
||||
- `error_message`: текст.
|
||||
- `current_url`, `page_text`, `screenshot` — для отладки.
|
||||
- Для `login_failed` дополнительно может быть объект `debug` (какой инпут использовался).
|
||||
|
||||
### Типы ошибок
|
||||
|
||||
| error_type | Описание |
|
||||
|---------------------------|----------|
|
||||
| `login_button_not_found` | Не найдена кнопка «Вход» на ej.sudrf.ru. |
|
||||
| `esia_redirect_failed` | После «Войти» не произошёл редирект на ЕСИА. |
|
||||
| `login_input_not_found` | Не найдено поле логина на странице ЕСИА. |
|
||||
| `password_input_not_found`| Не найдено поле пароля на ЕСИА. |
|
||||
| `login_failed` | После нажатия «Войти» на ЕСИА остались на /login/ (форма не приняла логин/пароль или валидация). |
|
||||
| `sms_page_not_found` | Не найдены поля для ввода SMS-кода. |
|
||||
|
||||
## Шаг 2: Ввод SMS и получение кук ej.sudrf.ru
|
||||
|
||||
Отдельный скрипт (или вторая нода) должен:
|
||||
|
||||
1. Принять от пользователя SMS-код (например через Telegram или Webhook).
|
||||
2. Восстановить сессию: передать в Browserless сохранённые `cookies` из шага 1.
|
||||
3. Открыть страницу ЕСИА с полями SMS (или текущий URL из шага 1).
|
||||
4. Ввести код по цифре в каждое поле (или в одно поле, в зависимости от разметки ЕСИА).
|
||||
5. Дождаться редиректа на `ej.sudrf.ru`.
|
||||
6. Собрать куки для ej.sudrf.ru (`PHPSESSID`, `fromOa` и др.) и вернуть их (например записать в файл или передать в следующую ноду).
|
||||
|
||||
Формат кук для последующих запросов к ej.sudrf.ru:
|
||||
|
||||
```
|
||||
PHPSESSID=...; fromOa=16RS0018
|
||||
```
|
||||
|
||||
## Технические детали
|
||||
|
||||
- **Куки в Browserless**: в окружении `/function` используется `page.cookies()`, а не `browserContext.cookies()`.
|
||||
- **Видимость полей**: на ЕСИА выбираются только видимые инпуты (проверка по `boundingBox`), чтобы не заполнять скрытые дубликаты.
|
||||
- **Таймауты**: по умолчанию используются таймауты порядка 15–30 с для навигации и ожидания селекторов; при необходимости их можно увеличить в параметрах вызова Browserless (`timeout` в URL).
|
||||
|
||||
## Файлы
|
||||
|
||||
- `browserless_login_esia.js` — скрипт шага 1 (до SMS).
|
||||
- Документация: этот файл (`docs/BROWSERLESS_EJ_SUDRF_ESIA_LOGIN.md`).
|
||||
|
||||
## См. также
|
||||
|
||||
- Настройка n8n: передача `login`/`pass` из предыдущей ноды (Set) или из Credentials (Custom Auth) в body HTTP Request к Browserless.
|
||||
- Сохранение кук: после шага 2 записать строку кук в файл или переменную для использования в запросах к ej.sudrf.ru (подача обращений и т.д.).
|
||||
Submodule erv_platform updated: b06fdb731c...3d121054ab
132
fix_all_collation_utf8mb3.php
Normal file
132
fix_all_collation_utf8mb3.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* Исправление всех таблиц с utf8mb3_bin на utf8mb4_general_ci
|
||||
*/
|
||||
|
||||
$dbHost = '192.168.128.3';
|
||||
$dbUser = 'nextcloud';
|
||||
$dbPass = 'nextcloud_password';
|
||||
$dbName = 'nextcloud';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4",
|
||||
$dbUser,
|
||||
$dbPass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ ВСЕХ ТАБЛИЦ С utf8mb3_bin ===\n\n";
|
||||
|
||||
// Находим все колонки с utf8mb3_bin
|
||||
$query = "
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
COLUMN_TYPE,
|
||||
CHARACTER_SET_NAME,
|
||||
COLLATION_NAME
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME LIKE 'oc_%'
|
||||
AND COLLATION_NAME LIKE '%utf8mb3%'
|
||||
ORDER BY TABLE_NAME, COLUMN_NAME
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($query);
|
||||
$stmt->execute([$dbName]);
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($columns)) {
|
||||
echo "✅ Все колонки уже имеют правильную collation!\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Найдено колонок с utf8mb3: " . count($columns) . "\n\n";
|
||||
|
||||
$fixed = 0;
|
||||
$errors = 0;
|
||||
$tables = [];
|
||||
|
||||
foreach ($columns as $col) {
|
||||
$table = $col['TABLE_NAME'];
|
||||
$column = $col['COLUMN_NAME'];
|
||||
$dataType = $col['DATA_TYPE'];
|
||||
$columnType = $col['COLUMN_TYPE'];
|
||||
$charSet = $col['CHARACTER_SET_NAME'];
|
||||
$collation = $col['COLLATION_NAME'];
|
||||
|
||||
// Группируем по таблицам
|
||||
if (!isset($tables[$table])) {
|
||||
$tables[$table] = [];
|
||||
}
|
||||
$tables[$table][] = $col;
|
||||
}
|
||||
|
||||
// Исправляем каждую таблицу
|
||||
foreach ($tables as $table => $tableColumns) {
|
||||
echo "Таблица: $table\n";
|
||||
|
||||
foreach ($tableColumns as $col) {
|
||||
$column = $col['COLUMN_NAME'];
|
||||
$columnType = $col['COLUMN_TYPE'];
|
||||
|
||||
// Получаем полную информацию о колонке
|
||||
$colInfoQuery = "SHOW FULL COLUMNS FROM `$table` WHERE Field = ?";
|
||||
$colInfoStmt = $pdo->prepare($colInfoQuery);
|
||||
$colInfoStmt->execute([$column]);
|
||||
$colInfo = $colInfoStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$colInfo) {
|
||||
echo " ⚠️ Не удалось получить информацию о колонке $column\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Строим ALTER TABLE запрос
|
||||
$type = $colInfo['Type'];
|
||||
// Заменяем utf8mb3 на utf8mb4
|
||||
$type = preg_replace('/utf8mb3/i', 'utf8mb4', $type);
|
||||
$type = preg_replace('/utf8(_bin)?/i', 'utf8mb4', $type);
|
||||
// Убираем старую collation и добавляем новую
|
||||
$type = preg_replace('/COLLATE\s+\w+/i', '', $type);
|
||||
$type = preg_replace('/CHARACTER\s+SET\s+\w+/i', '', $type);
|
||||
|
||||
// Добавляем новую collation
|
||||
if (preg_match('/varchar|char|text/i', $type)) {
|
||||
$type .= ' CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci';
|
||||
}
|
||||
|
||||
$null = $colInfo['Null'] === 'YES' ? 'NULL' : 'NOT NULL';
|
||||
$default = '';
|
||||
if ($colInfo['Default'] !== null) {
|
||||
$default = "DEFAULT '" . addslashes($colInfo['Default']) . "'";
|
||||
}
|
||||
$extra = $colInfo['Extra'] ?: '';
|
||||
|
||||
$alterQuery = "ALTER TABLE `$table` MODIFY COLUMN `$column` $type $null $default $extra";
|
||||
|
||||
try {
|
||||
echo " Исправляю: $column ... ";
|
||||
$pdo->exec($alterQuery);
|
||||
echo "✅\n";
|
||||
$fixed++;
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ Ошибка: " . $e->getMessage() . "\n";
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "\n=== РЕЗУЛЬТАТ ===\n";
|
||||
echo "Исправлено колонок: $fixed\n";
|
||||
echo "Ошибок: $errors\n";
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ Ошибка подключения к БД: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
30
fix_indexes_collation.sh
Executable file
30
fix_indexes_collation.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Исправление индексов с неправильной collation в Nextcloud
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ ИНДЕКСОВ С НЕПРАВИЛЬНОЙ COLLATION ==="
|
||||
echo ""
|
||||
|
||||
# Получаем список таблиц с проблемными индексами
|
||||
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "
|
||||
SELECT DISTINCT TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = 'nextcloud'
|
||||
AND TABLE_NAME LIKE 'oc_%'
|
||||
AND COLLATION = 'utf8mb3_general_ci';
|
||||
" 2>&1 | grep -v "Warning" | grep -v "TABLE_NAME" | while read table; do
|
||||
if [ -n "$table" ]; then
|
||||
echo "Проверяю таблицу: $table"
|
||||
# Получаем информацию об индексах
|
||||
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "SHOW INDEX FROM \`$table\`;" 2>&1 | grep -i "utf8mb3" || echo " ✅ Нет проблемных индексов"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== РЕКОМЕНДАЦИЯ ==="
|
||||
echo "Если проблема сохраняется, попробуйте:"
|
||||
echo "1. Пересоздать индексы через Nextcloud:"
|
||||
echo " docker exec nextcloud-fresh php occ db:add-missing-indices"
|
||||
echo ""
|
||||
echo "2. Или временно отключить синхронизацию в клиенте Nextcloud"
|
||||
echo " и открыть файлы через Web UI для индексации"
|
||||
|
||||
104
fix_nextcloud_collation.php
Normal file
104
fix_nextcloud_collation.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Исправление проблемы с collation в БД Nextcloud
|
||||
* Заменяет utf8mb3_general_ci на utf8mb4_general_ci
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ COLLATION В БД NEXTCLOUD ===\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
// Подключение к БД Nextcloud
|
||||
$host = '192.168.128.3';
|
||||
$user = 'nextcloud';
|
||||
$password = 'nextcloud_password';
|
||||
$database = 'nextcloud';
|
||||
|
||||
try {
|
||||
$db = new mysqli($host, $user, $password, $database);
|
||||
|
||||
if ($db->connect_error) {
|
||||
throw new Exception("Ошибка подключения: " . $db->connect_error);
|
||||
}
|
||||
|
||||
$db->set_charset('utf8mb4');
|
||||
|
||||
echo "✅ Подключились к БД Nextcloud\n\n";
|
||||
|
||||
// Находим таблицы с неправильной collation
|
||||
echo "🔍 Поиск таблиц с неправильной collation...\n";
|
||||
$result = $db->query("
|
||||
SELECT TABLE_NAME, TABLE_COLLATION
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = 'nextcloud'
|
||||
AND TABLE_COLLATION LIKE '%utf8mb3%'
|
||||
");
|
||||
|
||||
$tables = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$tables[] = $row['TABLE_NAME'];
|
||||
echo " - {$row['TABLE_NAME']}: {$row['TABLE_COLLATION']}\n";
|
||||
}
|
||||
|
||||
if (empty($tables)) {
|
||||
echo "✅ Все таблицы имеют правильную collation\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "\n📊 Найдено таблиц для исправления: " . count($tables) . "\n\n";
|
||||
|
||||
// Исправляем collation для каждой таблицы
|
||||
echo "🔧 Исправление collation...\n\n";
|
||||
|
||||
$fixed = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
echo " Исправление таблицы: {$table}... ";
|
||||
|
||||
// Изменяем collation таблицы
|
||||
$sql = "ALTER TABLE `{$table}` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci";
|
||||
|
||||
if ($db->query($sql)) {
|
||||
echo "✅\n";
|
||||
$fixed++;
|
||||
} else {
|
||||
echo "❌ Ошибка: " . $db->error . "\n";
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГИ:\n";
|
||||
echo "Исправлено таблиц: {$fixed}\n";
|
||||
echo "Ошибок: {$errors}\n\n";
|
||||
|
||||
// Проверяем результат
|
||||
echo "🔍 Проверка результата...\n";
|
||||
$result = $db->query("
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = 'nextcloud'
|
||||
AND TABLE_COLLATION LIKE '%utf8mb3%'
|
||||
");
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
if ($row['count'] == 0) {
|
||||
echo "✅ Все таблицы исправлены!\n";
|
||||
} else {
|
||||
echo "⚠️ Осталось таблиц с неправильной collation: {$row['count']}\n";
|
||||
}
|
||||
|
||||
$db->close();
|
||||
|
||||
echo "\n=== ГОТОВО ===\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "❌ Критическая ошибка: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
137
fix_nextcloud_collation_all.php
Normal file
137
fix_nextcloud_collation_all.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Исправление всех колонок с неправильной collation в Nextcloud
|
||||
* Исправляет utf8mb3_general_ci → utf8mb4_general_ci
|
||||
*/
|
||||
|
||||
$dbHost = '192.168.128.3';
|
||||
$dbUser = 'nextcloud';
|
||||
$dbPass = 'nextcloud_password';
|
||||
$dbName = 'nextcloud';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4",
|
||||
$dbUser,
|
||||
$dbPass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ COLLATION В NEXTCLOUD ===\n\n";
|
||||
|
||||
// Находим все колонки с неправильной collation
|
||||
$query = "
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_SET_NAME,
|
||||
COLLATION_NAME
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME LIKE 'oc_%'
|
||||
AND COLLATION_NAME = 'utf8mb3_general_ci'
|
||||
ORDER BY TABLE_NAME, COLUMN_NAME
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($query);
|
||||
$stmt->execute([$dbName]);
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($columns)) {
|
||||
echo "✅ Все колонки уже имеют правильную collation!\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Найдено колонок с неправильной collation: " . count($columns) . "\n\n";
|
||||
|
||||
$fixed = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($columns as $col) {
|
||||
$table = $col['TABLE_NAME'];
|
||||
$column = $col['COLUMN_NAME'];
|
||||
$dataType = $col['DATA_TYPE'];
|
||||
$charSet = $col['CHARACTER_SET_NAME'];
|
||||
|
||||
// Определяем новый тип данных
|
||||
$newCharSet = 'utf8mb4';
|
||||
$newCollation = 'utf8mb4_general_ci';
|
||||
|
||||
// Для TEXT типов нужно указать CHARACTER SET
|
||||
$alterQuery = "ALTER TABLE `$table` MODIFY COLUMN `$column` ";
|
||||
|
||||
if (in_array(strtoupper($dataType), ['VARCHAR', 'CHAR', 'TEXT', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT'])) {
|
||||
// Получаем текущие параметры колонки
|
||||
$colInfoQuery = "SHOW FULL COLUMNS FROM `$table` WHERE Field = ?";
|
||||
$colInfoStmt = $pdo->prepare($colInfoQuery);
|
||||
$colInfoStmt->execute([$column]);
|
||||
$colInfo = $colInfoStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($colInfo) {
|
||||
$type = $colInfo['Type'];
|
||||
// Заменяем charset в типе
|
||||
$type = preg_replace('/utf8mb3/i', 'utf8mb4', $type);
|
||||
$type = preg_replace('/utf8(_general_ci)?/i', 'utf8mb4', $type);
|
||||
|
||||
$null = $colInfo['Null'] === 'YES' ? 'NULL' : 'NOT NULL';
|
||||
$default = $colInfo['Default'] !== null ? "DEFAULT '{$colInfo['Default']}'" : '';
|
||||
$extra = $colInfo['Extra'] ?: '';
|
||||
|
||||
$alterQuery .= "$type CHARACTER SET $newCharSet COLLATE $newCollation $null $default $extra";
|
||||
} else {
|
||||
echo "⚠️ Не удалось получить информацию о колонке $table.$column\n";
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Для других типов просто меняем collation
|
||||
$alterQuery .= "`$column` $dataType CHARACTER SET $newCharSet COLLATE $newCollation";
|
||||
}
|
||||
|
||||
try {
|
||||
echo "Исправляю: $table.$column ... ";
|
||||
$pdo->exec($alterQuery);
|
||||
echo "✅\n";
|
||||
$fixed++;
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ Ошибка: " . $e->getMessage() . "\n";
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== РЕЗУЛЬТАТ ===\n";
|
||||
echo "Исправлено: $fixed\n";
|
||||
echo "Ошибок: $errors\n";
|
||||
|
||||
// Проверяем индексы
|
||||
echo "\n=== ПРОВЕРКА ИНДЕКСОВ ===\n";
|
||||
$indexQuery = "
|
||||
SELECT DISTINCT
|
||||
TABLE_NAME,
|
||||
INDEX_NAME
|
||||
FROM
|
||||
INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME LIKE 'oc_%'
|
||||
AND COLLATION = 'utf8mb3_general_ci'
|
||||
";
|
||||
|
||||
$indexStmt = $pdo->prepare($indexQuery);
|
||||
$indexStmt->execute([$dbName]);
|
||||
$indexes = $indexStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($indexes)) {
|
||||
echo "⚠️ Найдено индексов с неправильной collation: " . count($indexes) . "\n";
|
||||
echo "Индексы нужно пересоздать вручную или через Nextcloud\n";
|
||||
} else {
|
||||
echo "✅ Все индексы имеют правильную collation\n";
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ Ошибка подключения к БД: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
38
fix_nextcloud_issues.sh
Executable file
38
fix_nextcloud_issues.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Исправление проблем Nextcloud из страницы проверки безопасности
|
||||
|
||||
echo "=== ИСПРАВЛЕНИЕ ПРОБЛЕМ NEXTCLOUD ==="
|
||||
echo ""
|
||||
|
||||
# 1. Запуск background jobs вручную
|
||||
echo "1. Запуск background jobs..."
|
||||
docker exec nextcloud-fresh php occ background:cron 2>&1 | head -20
|
||||
|
||||
# 2. Проверка и исправление collation для поддержки 4-байтовых символов
|
||||
echo ""
|
||||
echo "2. Проверка collation для поддержки 4-байтовых символов..."
|
||||
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
COUNT(*) as bad_cols
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'nextcloud'
|
||||
AND TABLE_NAME LIKE 'oc_%'
|
||||
AND COLLATION_NAME = 'utf8mb3_general_ci'
|
||||
GROUP BY TABLE_NAME
|
||||
ORDER BY bad_cols DESC;
|
||||
" 2>&1 | grep -v "Warning"
|
||||
|
||||
echo ""
|
||||
echo "=== РЕКОМЕНДАЦИИ ==="
|
||||
echo ""
|
||||
echo "Для автоматического запуска background jobs добавьте в crontab:"
|
||||
echo "*/5 * * * * docker exec nextcloud-fresh php occ background:cron"
|
||||
echo ""
|
||||
echo "Или используйте webcron (менее надежно):"
|
||||
echo "docker exec nextcloud-fresh php occ config:app:set core backgroundjobs_mode --value='webcron'"
|
||||
echo ""
|
||||
echo "Для исправления collation запустите скрипт fix_nextcloud_collation_all.php"
|
||||
|
||||
150
fix_nextcloud_settings.php
Executable file
150
fix_nextcloud_settings.php
Executable file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
/**
|
||||
* Скрипт для настройки Nextcloud и защиты от удаления файлов
|
||||
*
|
||||
* Выполняет:
|
||||
* 1. Отключает DeleteOrphanedItems
|
||||
* 2. Включает readonly для External Storage
|
||||
* 3. Увеличивает retention корзины до 365 дней
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
echo "Настройка Nextcloud для защиты от удаления файлов\n";
|
||||
echo str_repeat("=", 80) . "\n\n";
|
||||
|
||||
// Проверяем, доступен ли Docker
|
||||
$dockerAvailable = shell_exec('which docker 2>/dev/null');
|
||||
if (!$dockerAvailable) {
|
||||
die("❌ Docker не найден. Убедитесь, что Docker установлен и доступен.\n");
|
||||
}
|
||||
|
||||
// Имя контейнера Nextcloud
|
||||
$containerName = 'nextcloud-fresh';
|
||||
$user = 'www-data';
|
||||
|
||||
// Проверяем, существует ли контейнер
|
||||
$containerExists = shell_exec("docker ps -a --filter 'name=$containerName' --format '{{.Names}}' 2>/dev/null");
|
||||
if (empty(trim($containerExists))) {
|
||||
echo "⚠️ Контейнер '$containerName' не найден.\n";
|
||||
echo "Попробуем найти контейнер Nextcloud...\n";
|
||||
|
||||
$allContainers = shell_exec("docker ps -a --format '{{.Names}}' 2>/dev/null");
|
||||
echo "Доступные контейнеры:\n";
|
||||
echo $allContainers . "\n";
|
||||
|
||||
echo "\nВведите имя контейнера Nextcloud (или нажмите Enter для пропуска): ";
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$line = fgets($handle);
|
||||
$containerName = trim($line);
|
||||
fclose($handle);
|
||||
|
||||
if (empty($containerName)) {
|
||||
echo "Пропускаем настройку Nextcloud.\n";
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Используем контейнер: $containerName\n\n";
|
||||
|
||||
$commands = [];
|
||||
$results = [];
|
||||
|
||||
// 1. Отключить DeleteOrphanedItems
|
||||
echo "1. Отключение DeleteOrphanedItems...\n";
|
||||
$jobId = 31; // ID задачи DeleteOrphanedItems
|
||||
$cmd = "docker exec -u $user $containerName php occ background-job:delete $jobId 2>&1";
|
||||
echo " Команда: $cmd\n";
|
||||
$output = shell_exec($cmd);
|
||||
$results['delete_orphaned'] = $output;
|
||||
echo " Результат: " . (empty($output) ? "✅ Команда выполнена" : $output) . "\n\n";
|
||||
|
||||
// Проверяем статус задачи
|
||||
$checkCmd = "docker exec -u $user $containerName php occ background-job:list 2>&1 | grep -i 'DeleteOrphanedItems' || echo 'Задача не найдена (возможно, уже отключена)'";
|
||||
$checkOutput = shell_exec($checkCmd);
|
||||
echo " Проверка: $checkOutput\n\n";
|
||||
|
||||
// 2. Включить readonly для External Storage
|
||||
echo "2. Включение readonly для External Storage...\n";
|
||||
// Сначала найдем ID внешнего хранилища
|
||||
$listCmd = "docker exec -u $user $containerName php occ files_external:list 2>&1";
|
||||
$listOutput = shell_exec($listCmd);
|
||||
echo " Список внешних хранилищ:\n";
|
||||
echo " $listOutput\n";
|
||||
|
||||
// Обычно ID = 1 для первого хранилища, но проверим
|
||||
$storageId = 1;
|
||||
$readonlyCmd = "docker exec -u $user $containerName php occ files_external:option $storageId readonly true 2>&1";
|
||||
echo " Команда: $readonlyCmd\n";
|
||||
$readonlyOutput = shell_exec($readonlyCmd);
|
||||
$results['readonly'] = $readonlyOutput;
|
||||
echo " Результат: " . (empty($readonlyOutput) || strpos($readonlyOutput, 'error') === false ? "✅ Readonly включен" : $readonlyOutput) . "\n\n";
|
||||
|
||||
// Проверяем настройки
|
||||
$verifyCmd = "docker exec -u $user $containerName php occ files_external:list --output json 2>&1";
|
||||
$verifyOutput = shell_exec($verifyCmd);
|
||||
echo " Проверка настроек:\n";
|
||||
echo " $verifyOutput\n\n";
|
||||
|
||||
// 3. Увеличить retention корзины до 365 дней
|
||||
echo "3. Увеличение retention корзины до 365 дней...\n";
|
||||
$retentionCmd = "docker exec -u $user $containerName php occ config:app:set files trashbin_retention_obligation --value=\"auto, 365\" 2>&1";
|
||||
echo " Команда: $retentionCmd\n";
|
||||
$retentionOutput = shell_exec($retentionCmd);
|
||||
$results['retention'] = $retentionOutput;
|
||||
echo " Результат: " . (empty($retentionOutput) || strpos($retentionOutput, 'error') === false ? "✅ Retention установлен на 365 дней" : $retentionOutput) . "\n\n";
|
||||
|
||||
// Проверяем текущее значение
|
||||
$checkRetentionCmd = "docker exec -u $user $containerName php occ config:app:get files trashbin_retention_obligation 2>&1";
|
||||
$checkRetentionOutput = shell_exec($checkRetentionCmd);
|
||||
echo " Текущее значение retention: $checkRetentionOutput\n\n";
|
||||
|
||||
// 4. Настройка регулярной индексации
|
||||
echo "4. Настройка регулярной индексации файлов...\n";
|
||||
echo " Рекомендуется добавить в crontab:\n";
|
||||
echo " 0 */6 * * * docker exec -u $user $containerName php occ files:scan --all\n";
|
||||
echo " (сканирование каждые 6 часов)\n\n";
|
||||
|
||||
// Создаем скрипт для cron
|
||||
$cronScript = <<<'SCRIPT'
|
||||
#!/bin/bash
|
||||
# Скрипт для регулярной индексации файлов Nextcloud
|
||||
# Запускать каждые 6 часов через cron
|
||||
|
||||
CONTAINER_NAME="nextcloud-fresh"
|
||||
USER="www-data"
|
||||
|
||||
# Сканируем все файлы
|
||||
docker exec -u $USER $CONTAINER_NAME php occ files:scan --all >> /var/log/nextcloud_scan.log 2>&1
|
||||
|
||||
# Сканируем только внешнее хранилище (быстрее)
|
||||
# docker exec -u $USER $CONTAINER_NAME php occ files:scan --path="/crm" >> /var/log/nextcloud_scan.log 2>&1
|
||||
|
||||
echo "$(date): Nextcloud files scan completed" >> /var/log/nextcloud_scan.log
|
||||
SCRIPT;
|
||||
|
||||
$cronScriptPath = '/var/www/fastuser/data/www/crm.clientright.ru/nextcloud_scan_files.sh';
|
||||
file_put_contents($cronScriptPath, $cronScript);
|
||||
chmod($cronScriptPath, 0755);
|
||||
echo " ✅ Создан скрипт: $cronScriptPath\n";
|
||||
echo " Для добавления в crontab выполните:\n";
|
||||
echo " crontab -e\n";
|
||||
echo " Добавьте строку: 0 */6 * * * $cronScriptPath\n\n";
|
||||
|
||||
echo str_repeat("=", 80) . "\n";
|
||||
echo "ИТОГОВЫЙ ОТЧЕТ:\n\n";
|
||||
|
||||
echo "✅ Выполнено:\n";
|
||||
echo " 1. DeleteOrphanedItems отключен\n";
|
||||
echo " 2. Readonly включен для External Storage\n";
|
||||
echo " 3. Retention корзины увеличен до 365 дней\n";
|
||||
echo " 4. Создан скрипт для регулярной индексации\n\n";
|
||||
|
||||
echo "⚠️ ВАЖНО:\n";
|
||||
echo " - Добавьте скрипт индексации в crontab\n";
|
||||
echo " - Проверьте логи Nextcloud на наличие ошибок\n";
|
||||
echo " - Регулярно проверяйте статус задач: docker exec -u $user $containerName php occ background-job:list\n\n";
|
||||
|
||||
echo "📝 Логи команд сохранены в переменных \$results\n";
|
||||
|
||||
@@ -115,10 +115,34 @@ try {
|
||||
continue; // Пропускаем пустые сообщения
|
||||
}
|
||||
|
||||
// Формируем timestamp в ISO формате для JavaScript
|
||||
$timestamp = null;
|
||||
if (isset($item['created_at']) && !empty($item['created_at'])) {
|
||||
$createdAt = $item['created_at'];
|
||||
// Если created_at уже в ISO формате (содержит 'T'), используем как есть
|
||||
if (strpos($createdAt, 'T') !== false) {
|
||||
// Уже в ISO формате (например, "2025-11-14T06:21:55.207Z"), используем как есть
|
||||
$timestamp = $createdAt;
|
||||
} else {
|
||||
// Если в другом формате, преобразуем в ISO
|
||||
$parsedTime = strtotime($createdAt);
|
||||
if ($parsedTime !== false) {
|
||||
$timestamp = date('c', $parsedTime); // ISO 8601 формат
|
||||
} else {
|
||||
// Если не удалось распарсить, используем текущее время
|
||||
error_log("Chat History: Failed to parse created_at: {$createdAt}, using current time");
|
||||
$timestamp = date('c');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Если нет created_at, используем текущее время в ISO формате
|
||||
$timestamp = date('c'); // ISO 8601 формат
|
||||
}
|
||||
|
||||
$message = [
|
||||
'type' => isset($item['sender_type']) && $item['sender_type'] === 'user' ? 'user' : 'assistant',
|
||||
'message' => $item['content'] ?? '',
|
||||
'timestamp' => isset($item['created_at']) ? date('H:i:s', strtotime($item['created_at'])) : date('H:i:s'),
|
||||
'timestamp' => $timestamp,
|
||||
'id' => $item['id'] ?? '',
|
||||
'dialog_id' => $item['dialog_id'] ?? ''
|
||||
];
|
||||
@@ -131,7 +155,7 @@ try {
|
||||
$history[] = [
|
||||
'type' => 'assistant',
|
||||
'message' => "Привет! Я ваш AI ассистент. Работаем с '{$projectName}'. Чем могу помочь?",
|
||||
'timestamp' => date('H:i:s'),
|
||||
'timestamp' => date('c'), // ISO 8601 формат
|
||||
'id' => 'welcome-' . time(),
|
||||
'dialog_id' => 'new-dialog'
|
||||
];
|
||||
|
||||
175
include/Webservices/CreateClientProject.php
Normal file
175
include/Webservices/CreateClientProject.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания Проекта КлиентПрав из web-формы ticket_form
|
||||
* Уникальность проекта обеспечивается по claim_id (cf_2620)
|
||||
* Автор: GPT-5.1 Codex & Фёдор, 2025-11-15
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
include_once 'modules/Users/Users.php';
|
||||
require_once('include/Webservices/Utils.php');
|
||||
require_once 'include/Webservices/Create.php';
|
||||
require_once 'include/Webservices/Retrieve.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport ('includes.runtime.Globals');
|
||||
vimport ('includes.runtime.BaseModel');
|
||||
vimport ('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Создание проекта КлиентПрав по заявке ticket_form
|
||||
*
|
||||
* @param string $contact_id - ID контакта (обязательное)
|
||||
* @param string $claim_id - Уникальный ID обращения (обязательное, cf_2620)
|
||||
* @param string $session_id - Сессия фронтенда (опционально, cf_2618)
|
||||
* @param string $description - Описание пользователя (опционально, description)
|
||||
* @param string $ai_response - Ответ AI модели (опционально, cf_2622)
|
||||
* @param string $phone - Телефон для генерации имени (опционально)
|
||||
* @param string $firstname - Имя контакта (опционально)
|
||||
* @param string $lastname - Фамилия контакта (опционально)
|
||||
* @return array {project_id, project_name, is_new}
|
||||
*/
|
||||
function vtws_createclientproject($contact_id, $claim_id, $session_id = '', $description = '', $ai_response = '', $phone = '', $firstname = '', $lastname = '', $user = false) {
|
||||
|
||||
ob_start();
|
||||
|
||||
$logPrefix = date("Y-m-d H:i:s") . ' ';
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . json_encode($_REQUEST) . PHP_EOL, FILE_APPEND);
|
||||
|
||||
try {
|
||||
global $adb, $current_user;
|
||||
|
||||
if (empty($claim_id)) {
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан claim_id");
|
||||
}
|
||||
|
||||
if (empty($contact_id)) {
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID контакта");
|
||||
}
|
||||
|
||||
$claim_id = trim($claim_id);
|
||||
$session_id = trim($session_id);
|
||||
|
||||
// Нормализуем contact_id
|
||||
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
|
||||
$contactIdWithPrefix = '12x' . $contactIdNumeric;
|
||||
|
||||
// Подтягиваем данные контакта (фамилию/телефон), если не переданы
|
||||
if (empty($lastname) || empty($firstname) || empty($phone)) {
|
||||
try {
|
||||
$contactRecord = vtws_retrieve($contactIdWithPrefix, $current_user);
|
||||
if (empty($lastname) && !empty($contactRecord['lastname'])) {
|
||||
$lastname = $contactRecord['lastname'];
|
||||
}
|
||||
if (empty($firstname) && !empty($contactRecord['firstname'])) {
|
||||
$firstname = $contactRecord['firstname'];
|
||||
}
|
||||
if (empty($phone) && !empty($contactRecord['phone'])) {
|
||||
$phone = preg_replace('/[^0-9]/', '', $contactRecord['phone']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . '⚠️ Не удалось получить контакт: ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = false;
|
||||
$output = null;
|
||||
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . "🔎 Ищем проект по claim_id={$claim_id}" . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Ищем проект по claim_id (cf_2620)
|
||||
$query = "SELECT p.projectid
|
||||
FROM vtiger_project p
|
||||
INNER JOIN vtiger_projectcf pcf ON p.projectid = pcf.projectid
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
|
||||
WHERE e.deleted = 0
|
||||
AND pcf.cf_2620 = ?
|
||||
LIMIT 1";
|
||||
$result = $adb->pquery($query, array($claim_id));
|
||||
|
||||
if (!$result) {
|
||||
throw new Exception("SQL error while searching project");
|
||||
}
|
||||
|
||||
$projectName = '';
|
||||
|
||||
if ($adb->num_rows($result) > 0) {
|
||||
$output = $adb->query_result($result, 0, 'projectid');
|
||||
$isNew = false;
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . "✅ Проект найден по claim_id {$claim_id}: {$output}" . PHP_EOL, FILE_APPEND);
|
||||
} else {
|
||||
// Генерируем имя проекта
|
||||
$lastname = trim($lastname);
|
||||
if (!empty($lastname)) {
|
||||
$projectName = $lastname . '_КлиентПрав';
|
||||
} elseif (!empty($phone)) {
|
||||
$projectName = $phone . '_КлиентПрав';
|
||||
} else {
|
||||
$projectName = 'КлиентПрав_' . $claim_id;
|
||||
}
|
||||
|
||||
$params = array(
|
||||
'projectname' => $projectName,
|
||||
'projectstatus' => 'Черновик',
|
||||
'projecttype' => 'претензионно-исковая работа',
|
||||
'linktoaccountscontacts' => $contactIdWithPrefix,
|
||||
'cf_1994' => '11x62345', // Заявитель (МОО КлиентПрав)
|
||||
'cf_2620' => $claim_id,
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
|
||||
);
|
||||
|
||||
if (!empty($session_id)) {
|
||||
$params['cf_2618'] = $session_id;
|
||||
}
|
||||
if (!empty($description)) {
|
||||
$params['description'] = $description;
|
||||
}
|
||||
if (!empty($ai_response)) {
|
||||
$params['cf_2622'] = $ai_response;
|
||||
}
|
||||
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . 'Массив для создания: ' . json_encode($params) . PHP_EOL, FILE_APPEND);
|
||||
|
||||
try {
|
||||
$project = vtws_create('Project', $params, $current_user);
|
||||
$output = substr($project['id'], 3);
|
||||
$isNew = true;
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . "✅ Создан новый проект: {$output}" . PHP_EOL, FILE_APPEND);
|
||||
} catch (WebServiceException $ex) {
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . '❌ Ошибка создания: ' . $ex->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем название проекта (если проект был найден, а не создан)
|
||||
if (empty($projectName) && !empty($output)) {
|
||||
try {
|
||||
$query = "SELECT projectname FROM vtiger_project WHERE projectid = ? LIMIT 1";
|
||||
$result = $adb->pquery($query, array($output));
|
||||
if ($adb->num_rows($result) > 0) {
|
||||
$projectName = $adb->query_result($result, 0, 'projectname');
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . "📝 Получено название проекта: {$projectName}" . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . '⚠️ Не удалось получить название проекта: ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
$result = array(
|
||||
'project_id' => $output,
|
||||
'project_name' => $projectName,
|
||||
'is_new' => $isNew
|
||||
);
|
||||
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . 'Return: ' . json_encode($result) . PHP_EOL, FILE_APPEND);
|
||||
ob_end_clean();
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $ex) {
|
||||
file_put_contents('logs/CreateClientProject.log', $logPrefix . '❌ Exception: ' . $ex->getMessage() . PHP_EOL, FILE_APPEND);
|
||||
ob_end_clean();
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Нормализуем ID контакта и проекта (можно передавать как "12x123" или "123")
|
||||
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
|
||||
$projectIdNumeric = preg_replace('/[^0-9]/', '', $project_id);
|
||||
|
||||
$contactWsId = '12x' . $contactIdNumeric;
|
||||
$projectWsId = '33x' . $projectIdNumeric;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Нормализовали ID: contact='.$contactIdNumeric.' (raw='.$contact_id.'), project='.$projectIdNumeric.' (raw='.$project_id.')'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
|
||||
|
||||
// Маппинг типов событий на русские названия для категории
|
||||
$eventTypeMap = array(
|
||||
'delay_flight' => 'Задержка рейса',
|
||||
@@ -108,8 +118,8 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
|
||||
'parent_id' => '11x67458', // Заявитель - контрагент
|
||||
'ticketcategories' => $ticketCategory,
|
||||
'ticketstatus' => 'рассмотрение',
|
||||
'contact_id' => '12x'.$contact_id,
|
||||
'cf_2066' => '33x'.$project_id, // Связь с проектом
|
||||
'contact_id' => $contactWsId,
|
||||
'cf_2066' => $projectWsId, // Связь с проектом
|
||||
'ticketpriorities' => 'High',
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id),
|
||||
'description' => $fullDescription
|
||||
@@ -126,6 +136,32 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Создана Заявка id='.$ticketId.' ticket_no='.$ticketNumber.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
|
||||
|
||||
// 🚧 Создаём двустороннюю связь между Проектом и Заявкой
|
||||
try {
|
||||
$relationCheck = $adb->pquery(
|
||||
"SELECT 1 FROM vtiger_crmentityrel
|
||||
WHERE (crmid = ? AND relcrmid = ?)
|
||||
OR (crmid = ? AND relcrmid = ?)
|
||||
LIMIT 1",
|
||||
array($projectIdNumeric, $ticketId, $ticketId, $projectIdNumeric)
|
||||
);
|
||||
|
||||
if (!$relationCheck || $adb->num_rows($relationCheck) === 0) {
|
||||
$adb->pquery(
|
||||
"INSERT INTO vtiger_crmentityrel (crmid, module, relcrmid, relmodule) VALUES (?, ?, ?, ?)",
|
||||
array($projectIdNumeric, 'Project', $ticketId, 'HelpDesk')
|
||||
);
|
||||
$logstring = date('Y-m-d H:i:s').' 🔗 Добавлена связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.')'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
|
||||
} else {
|
||||
$logstring = date('Y-m-d H:i:s').' 🔗 Связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.') уже существует'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
|
||||
}
|
||||
} catch (Exception $relEx) {
|
||||
$logstring = date('Y-m-d H:i:s').' ⚠️ Ошибка связывания Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.'): '.$relEx->getMessage().PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
|
||||
}
|
||||
|
||||
// Возвращаем массив (vTiger сам сделает json_encode)
|
||||
$output = array(
|
||||
'ticket_id' => $ticketId,
|
||||
@@ -150,3 +186,7 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
288
include/Webservices/CreateWebClaimV2.php
Normal file
288
include/Webservices/CreateWebClaimV2.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания Заявки (HelpDesk) из Web-формы (V2 - JSON версия)
|
||||
* Принимает JSON строку с данными заявки
|
||||
* Автор: Фёдор, 2025-12-29
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
include_once 'modules/Users/Users.php';
|
||||
require_once('include/Webservices/Utils.php');
|
||||
require_once 'include/Webservices/Create.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport ('includes.runtime.Globals');
|
||||
vimport ('includes.runtime.BaseModel');
|
||||
vimport ('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Создание заявки из web-формы ERV Platform (V2 - JSON версия)
|
||||
*
|
||||
* @param string $claim_json - JSON строка с данными заявки (обязательно)
|
||||
* @param object $user - пользователь (опционально)
|
||||
* @return array - {"ticket_id": "123", "ticket_number": "TT12345", "title": "...", "category": "...", "status": "..."}
|
||||
*/
|
||||
function vtws_createwebclaimv2($claim_json, $user = false) {
|
||||
|
||||
$logstring = date("Y-m-d H:i:s").' REQUEST: '.json_encode($_REQUEST);
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Проверка обязательного параметра
|
||||
if(empty($claim_json)){
|
||||
$logstring = date("Y-m-d H:i:s").' Не передан параметр claim_json';
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не передан параметр claim_json");
|
||||
}
|
||||
|
||||
// Парсим JSON
|
||||
$claimData = json_decode($claim_json, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Пробуем очистить от возможных лишних символов
|
||||
$cleanedJson = trim($claim_json);
|
||||
$cleanedJson = preg_replace('/^[^{]*/', '', $cleanedJson); // Убираем всё до первой {
|
||||
$cleanedJson = preg_replace('/[^}]*$/', '', $cleanedJson); // Убираем всё после последней }
|
||||
$claimData = json_decode($cleanedJson, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$logstring = date("Y-m-d H:i:s").' Ошибка парсинга JSON: '.json_last_error_msg().', JSON: '.substr($claim_json, 0, 200);
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Ошибка парсинга JSON: ".json_last_error_msg());
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date("Y-m-d H:i:s").' CLEANED JSON: '.json_encode($claimData);
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Извлекаем обязательные поля
|
||||
$project_id = isset($claimData['project_id']) ? $claimData['project_id'] : '';
|
||||
$contact_id = isset($claimData['contact_id']) ? $claimData['contact_id'] : '';
|
||||
$event_type = isset($claimData['cf_1726']) ? $claimData['cf_1726'] : '';
|
||||
$description = isset($claimData['description']) ? $claimData['description'] : '';
|
||||
|
||||
// Проверка обязательных полей
|
||||
if(empty($project_id)){
|
||||
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: project_id';
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID проекта");
|
||||
}
|
||||
|
||||
if(empty($contact_id)){
|
||||
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: contact_id';
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID контакта");
|
||||
}
|
||||
|
||||
if(empty($event_type)){
|
||||
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: cf_1726 (event_type)';
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан тип страхового случая");
|
||||
}
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Нормализуем ID контакта и проекта
|
||||
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
|
||||
$projectIdNumeric = preg_replace('/[^0-9]/', '', $project_id);
|
||||
|
||||
$contactWsId = '12x' . $contactIdNumeric;
|
||||
$projectWsId = '33x' . $projectIdNumeric;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Нормализовали ID: contact='.$contactIdNumeric.' (raw='.$contact_id.'), project='.$projectIdNumeric.' (raw='.$project_id.')'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
|
||||
// Маппинг типов событий на русские названия для cf_2650
|
||||
$eventTypeMap = array(
|
||||
'delay_flight' => 'Задержка рейса',
|
||||
'cancel_flight' => 'Отмена рейса',
|
||||
'miss_connection' => 'Пропуск стыковки',
|
||||
'missed_connection' => 'Пропуск стыковки',
|
||||
'delay_train' => 'Задержка поезда',
|
||||
'cancel_train' => 'Отмена поезда',
|
||||
'delay_ferry' => 'Задержка парома',
|
||||
'cancel_ferry' => 'Отмена парома'
|
||||
);
|
||||
|
||||
// ticketcategories всегда "Цифровой адвокат ЕРВ"
|
||||
$ticketCategory = 'Цифровой адвокат ЕРВ';
|
||||
|
||||
// Нормализуем event_type для cf_2650
|
||||
$normalizedEventType = isset($eventTypeMap[$event_type]) ? $eventTypeMap[$event_type] : 'Цифровой адвокат ЕРВ';
|
||||
|
||||
// Извлекаем дополнительные поля
|
||||
$incident_date = isset($claimData['cf_2566']) ? $claimData['cf_2566'] : '';
|
||||
$transport_number = isset($claimData['cf_2568']) ? $claimData['cf_2568'] : '';
|
||||
$cf_1885 = isset($claimData['cf_1885']) ? $claimData['cf_1885'] : '';
|
||||
$lastname = isset($claimData['lastname']) ? $claimData['lastname'] : '';
|
||||
$firstname = isset($claimData['firstname']) ? $claimData['firstname'] : '';
|
||||
|
||||
// Формируем ticket_title: event_type_cf_1885_lastname_firstname
|
||||
$ticket_title = $event_type;
|
||||
if (!empty($cf_1885)) {
|
||||
$ticket_title .= '_' . $cf_1885;
|
||||
}
|
||||
if (!empty($lastname)) {
|
||||
$ticket_title .= '_' . $lastname;
|
||||
}
|
||||
if (!empty($firstname)) {
|
||||
$ticket_title .= '_' . $firstname;
|
||||
}
|
||||
|
||||
// Формируем описание
|
||||
$fullDescription = '';
|
||||
if (!empty($description)) {
|
||||
$fullDescription .= $description . "\n\n";
|
||||
}
|
||||
|
||||
$fullDescription .= "Тип события: " . $normalizedEventType . "\n";
|
||||
|
||||
if (!empty($incident_date)) {
|
||||
$fullDescription .= "Дата инцидента: " . $incident_date . "\n";
|
||||
}
|
||||
if (!empty($transport_number)) {
|
||||
$fullDescription .= "Номер рейса: " . $transport_number . "\n";
|
||||
}
|
||||
|
||||
// Добавляем cf_departure_flight и cf_departure_date, если есть
|
||||
$cf_departure_flight = isset($claimData['cf_departure_flight']) ? $claimData['cf_departure_flight'] : '';
|
||||
$cf_departure_date = isset($claimData['cf_departure_date']) ? $claimData['cf_departure_date'] : '';
|
||||
|
||||
if (!empty($cf_departure_flight)) {
|
||||
$fullDescription .= "Рейс стыковки: " . $cf_departure_flight . "\n";
|
||||
}
|
||||
if (!empty($cf_departure_date)) {
|
||||
$fullDescription .= "Дата стыковки: " . $cf_departure_date . "\n";
|
||||
}
|
||||
|
||||
$fullDescription .= "\nИсточник: ERV Platform Web Form";
|
||||
|
||||
// Формируем массив параметров для создания заявки
|
||||
$params = array(
|
||||
'ticket_title' => $ticket_title,
|
||||
'parent_id' => '11x67458', // Заявитель - контрагент
|
||||
'ticketcategories' => $ticketCategory,
|
||||
'ticketstatus' => 'рассмотрение',
|
||||
'contact_id' => $contactWsId,
|
||||
'cf_2066' => $projectWsId, // Связь с проектом
|
||||
'ticketpriorities' => 'High',
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id),
|
||||
'description' => $fullDescription,
|
||||
'cf_1726' => $event_type, // Сырой тип события
|
||||
'cf_2650' => $normalizedEventType // Нормализованный тип события
|
||||
);
|
||||
|
||||
// Маппинг дополнительных полей
|
||||
if (!empty($incident_date)) {
|
||||
$params['cf_2566'] = $incident_date;
|
||||
}
|
||||
if (!empty($transport_number)) {
|
||||
$params['cf_2568'] = $transport_number;
|
||||
}
|
||||
if (!empty($cf_departure_flight)) {
|
||||
$params['cf_2630'] = $cf_departure_flight;
|
||||
}
|
||||
if (!empty($cf_departure_date)) {
|
||||
$params['cf_2632'] = $cf_departure_date;
|
||||
}
|
||||
|
||||
// Страна (cf_1909 → cf_2636)
|
||||
if (isset($claimData['cf_1909']) && !empty($claimData['cf_1909'])) {
|
||||
$params['cf_2636'] = $claimData['cf_1909'];
|
||||
}
|
||||
|
||||
// cf_2502 → cf_2572
|
||||
if (isset($claimData['cf_2502']) && !empty($claimData['cf_2502'])) {
|
||||
$params['cf_2572'] = $claimData['cf_2502'];
|
||||
}
|
||||
|
||||
// code → cf_2574
|
||||
if (isset($claimData['code']) && !empty($claimData['code'])) {
|
||||
$params['cf_2574'] = $claimData['code'];
|
||||
}
|
||||
|
||||
// cf_1885 → cf_2642
|
||||
if (!empty($cf_1885)) {
|
||||
$params['cf_2642'] = $cf_1885;
|
||||
}
|
||||
|
||||
// IP → cf_2634
|
||||
if (isset($claimData['ip']) && !empty($claimData['ip'])) {
|
||||
$params['cf_2634'] = $claimData['ip'];
|
||||
}
|
||||
|
||||
// region → cf_2640
|
||||
if (isset($claimData['region']) && !empty($claimData['region'])) {
|
||||
$params['cf_2640'] = $claimData['region'];
|
||||
}
|
||||
|
||||
// source → cf_2638
|
||||
if (isset($claimData['source']) && !empty($claimData['source'])) {
|
||||
$params['cf_2638'] = $claimData['source'];
|
||||
}
|
||||
|
||||
// cf_2508 → cf_2508 (прямое маппирование)
|
||||
if (isset($claimData['cf_2508']) && !empty($claimData['cf_2508'])) {
|
||||
$params['cf_2508'] = $claimData['cf_2508'];
|
||||
}
|
||||
|
||||
// cf_2648 → cf_2648 (прямое маппирование)
|
||||
if (isset($claimData['cf_2648']) && !empty($claimData['cf_2648'])) {
|
||||
$params['cf_2648'] = $claimData['cf_2648'];
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Массив для создания Заявки: '.json_encode($params).PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
|
||||
try {
|
||||
$result = vtws_create('HelpDesk', $params, $current_user);
|
||||
|
||||
$ticketId = substr($result['id'], 3); // Убираем префикс "17x"
|
||||
$ticketNumber = isset($result['ticket_no']) ? $result['ticket_no'] : 'N/A';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Создана Заявка id='.$ticketId.' ticket_no='.$ticketNumber.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
|
||||
// Создаём двустороннюю связь между Проектом и Заявкой
|
||||
try {
|
||||
$relationCheck = $adb->pquery(
|
||||
"SELECT 1 FROM vtiger_crmentityrel
|
||||
WHERE (crmid = ? AND relcrmid = ?)
|
||||
OR (crmid = ? AND relcrmid = ?)
|
||||
LIMIT 1",
|
||||
array($projectIdNumeric, $ticketId, $ticketId, $projectIdNumeric)
|
||||
);
|
||||
|
||||
if (!$relationCheck || $adb->num_rows($relationCheck) === 0) {
|
||||
$adb->pquery(
|
||||
"INSERT INTO vtiger_crmentityrel (crmid, module, relcrmid, relmodule) VALUES (?, ?, ?, ?)",
|
||||
array($projectIdNumeric, 'Project', $ticketId, 'HelpDesk')
|
||||
);
|
||||
$logstring = date('Y-m-d H:i:s').' 🔗 Добавлена связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.')'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
} else {
|
||||
$logstring = date('Y-m-d H:i:s').' 🔗 Связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.') уже существует'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
}
|
||||
} catch (Exception $relEx) {
|
||||
$logstring = date('Y-m-d H:i:s').' ⚠️ Ошибка связывания Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.'): '.$relEx->getMessage().PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
}
|
||||
|
||||
// Возвращаем массив
|
||||
$output = array(
|
||||
'ticket_id' => $ticketId,
|
||||
'ticket_number' => $ticketNumber,
|
||||
'title' => $ticket_title,
|
||||
'category' => $ticketCategory,
|
||||
'status' => 'рассмотрение'
|
||||
);
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
throw $ex;
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($output).PHP_EOL;
|
||||
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
|
||||
|
||||
return $output;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ vimport ('includes.runtime.LanguageHandler');
|
||||
* @param string $firstname - имя (опционально)
|
||||
* @param string $lastname - фамилия (опционально)
|
||||
* @param string $email - email (опционально)
|
||||
* @return int - ID контакта
|
||||
* @return string - JSON строка с contact_id, is_new и cf_2624 (Данные подтверждены)
|
||||
*/
|
||||
function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email = '', $user = false) {
|
||||
|
||||
@@ -56,18 +56,29 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
|
||||
$isNew = false; // Флаг: создан ли контакт сейчас
|
||||
|
||||
// Проверяем существование контакта по номеру телефона
|
||||
$query = "select c.contactid
|
||||
// ✅ Добавляем выборку поля cf_2624 (Данные подтверждены)
|
||||
$query = "select c.contactid, cf.cf_2624
|
||||
from vtiger_contactdetails c
|
||||
left join vtiger_crmentity e on e.crmid = c.contactid
|
||||
left join vtiger_crmentity e on e.crmid = c.contactid
|
||||
left join vtiger_contactscf cf on cf.contactid = c.contactid
|
||||
where e.deleted = 0 and c.mobile = ?
|
||||
limit 1";
|
||||
$result = $adb->pquery($query, array($mobile));
|
||||
|
||||
$cf_2624_value = "0"; // По умолчанию "Нет" (данные не подтверждены)
|
||||
|
||||
if ($adb->num_rows($result) > 0) {
|
||||
// Контакт существует - ПРОСТО ВОЗВРАЩАЕМ ID (НЕ обновляем!)
|
||||
$output = $adb->query_result($result, 0, 'contactid');
|
||||
$isNew = false;
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Контакт найден с id '.$output.' (БЕЗ обновления)'.PHP_EOL;
|
||||
|
||||
// ✅ Получаем значение поля cf_2624 (Данные подтверждены)
|
||||
$cf_2624_value = $adb->query_result($result, 0, 'cf_2624');
|
||||
if (empty($cf_2624_value)) {
|
||||
$cf_2624_value = "0"; // По умолчанию "Нет"
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Контакт найден с id '.$output.', cf_2624='.$cf_2624_value.' (БЕЗ обновления)'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebContact.log', $logstring, FILE_APPEND);
|
||||
} else {
|
||||
// Контакт НЕ существует - создаём новый
|
||||
@@ -92,6 +103,7 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
|
||||
'mailingstreet' => '', // Адрес пустой
|
||||
'cf_1849' => '', // Реквизиты пустые
|
||||
'cf_1580' => '', // Код пустой
|
||||
'cf_2624' => '0', // ✅ Данные подтверждены = "Нет" (по умолчанию для новых контактов)
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
|
||||
);
|
||||
|
||||
@@ -102,7 +114,8 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
|
||||
$contact = vtws_create('Contacts', $params, $current_user);
|
||||
$output = substr($contact['id'], 3);
|
||||
$isNew = true; // Контакт только что создан!
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Создан новый Web Контакт с id '.$output.PHP_EOL;
|
||||
$cf_2624_value = "0"; // Новый контакт - данные не подтверждены
|
||||
$logstring = date('Y-m-d H:i:s').' ✅ Создан новый Web Контакт с id '.$output.', cf_2624=0'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebContact.log', $logstring, FILE_APPEND);
|
||||
} catch (WebServiceException $ex) {
|
||||
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
|
||||
@@ -111,10 +124,11 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем JSON с флагом is_new
|
||||
// Возвращаем JSON с флагом is_new и значением cf_2624
|
||||
$result = array(
|
||||
'contact_id' => $output,
|
||||
'is_new' => $isNew
|
||||
'is_new' => $isNew,
|
||||
'cf_2624' => $cf_2624_value // ✅ "1" = данные подтверждены, "0" = не подтверждены
|
||||
);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($result).PHP_EOL;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания Проекта из Web-формы (упрощённый)
|
||||
* Обязательное поле: cf_1885 (номер полиса)
|
||||
* Логика: если проект с таким полисом существует - возвращаем ID БЕЗ обновления
|
||||
* Автор: Фёдор, 2025-11-01
|
||||
* Обязательные поля: cf_1885 (номер полиса), contact_id (контакт)
|
||||
* Логика: если проект с таким полисом И привязкой к контакту существует - возвращаем ID БЕЗ обновления
|
||||
* (по одному полису может быть несколько застрахованных лиц!)
|
||||
* Автор: Фёдор, 2025-11-02
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
@@ -17,7 +18,8 @@ vimport ('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Создание проекта из web-формы
|
||||
* Если проект с таким номером полиса уже существует - просто возвращаем его ID
|
||||
* Если проект с таким номером полиса И привязкой к контакту уже существует - просто возвращаем его ID
|
||||
* (один полис может быть у нескольких застрахованных лиц!)
|
||||
* @param string $policy_number - номер полиса (обязательное поле) - cf_1885
|
||||
* @param string $contact_id - ID контакта для привязки (обязательное поле)
|
||||
* @param string $period_start - дата начала страхования (опционально) - cf_1887
|
||||
@@ -50,21 +52,28 @@ function vtws_createwebproject($policy_number, $contact_id, $period_start = '',
|
||||
// Валидация: убираем пробелы из номера полиса
|
||||
$policy_number = trim($policy_number);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Ищем проект по policy_number='.$policy_number.PHP_EOL;
|
||||
// Нормализуем contact_id: допускаем как "12x12345", так и "12345"
|
||||
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
|
||||
$contactIdWithPrefix = '12x' . $contactIdNumeric;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s').' Ищем проект по policy_number='.$policy_number.' И contact_id='.$contactIdNumeric.' (raw='.$contact_id.')'.PHP_EOL;
|
||||
file_put_contents('logs/CreateWebProject.log', $logstring, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
$isNew = false; // Флаг: создан ли проект сейчас
|
||||
|
||||
// Проверяем существование проекта по номеру полиса
|
||||
// Проверяем существование проекта по номеру полиса И прямой привязке к контакту
|
||||
// (без зависимости от заполнения vtiger_crmentityrel)
|
||||
$query = "SELECT p.projectid
|
||||
FROM vtiger_project p
|
||||
INNER JOIN vtiger_projectcf pcf ON p.projectid = pcf.projectid
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
|
||||
WHERE e.deleted = 0 AND pcf.cf_1885 = ?
|
||||
INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
|
||||
WHERE e.deleted = 0
|
||||
AND pcf.cf_1885 = ?
|
||||
AND p.linktoaccountscontacts = ?
|
||||
LIMIT 1";
|
||||
$result = $adb->pquery($query, array($policy_number));
|
||||
$result = $adb->pquery($query, array($policy_number, $contactIdNumeric));
|
||||
|
||||
if ($adb->num_rows($result) > 0) {
|
||||
// Проект существует - ПРОСТО ВОЗВРАЩАЕМ ID (НЕ обновляем!)
|
||||
@@ -82,7 +91,7 @@ function vtws_createwebproject($policy_number, $contact_id, $period_start = '',
|
||||
'projectname' => $projectname,
|
||||
'projectstatus' => 'модерация',
|
||||
'projecttype' => 'ерв урегулирование',
|
||||
'linktoaccountscontacts' => '12x'.$contact_id, // Привязка к контакту
|
||||
'linktoaccountscontacts' => $contactIdWithPrefix, // Привязка к контакту
|
||||
'cf_1994' => '11x67458', // Заявитель (контрагент record=67458)
|
||||
'cf_1885' => $policy_number, // Номер полиса
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
|
||||
@@ -126,4 +135,5 @@ function vtws_createwebproject($policy_number, $contact_id, $period_start = '',
|
||||
ob_end_clean();
|
||||
|
||||
return $result; // ← Массив, НЕ json_encode!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
225
include/Webservices/UpsertAccounts.php
Normal file
225
include/Webservices/UpsertAccounts.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/поиска нескольких Контрагентов (Upsert Batch)
|
||||
*
|
||||
* Принимает JSON массив offenders, для каждого:
|
||||
* - Ищет по ИНН
|
||||
* - Если найден — возвращает ID (БЕЗ обновления)
|
||||
* - Если не найден — создаёт новый
|
||||
*
|
||||
* Возвращает массив результатов с account_id для каждого offender
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
include_once 'modules/Users/Users.php';
|
||||
require_once('include/Webservices/Utils.php');
|
||||
require_once 'include/Webservices/Create.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport('includes.runtime.Globals');
|
||||
vimport('includes.runtime.BaseModel');
|
||||
vimport('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Upsert нескольких контрагентов
|
||||
*
|
||||
* @param string $offenders_json - JSON массив offenders:
|
||||
* [
|
||||
* {
|
||||
* "accountname": "ООО Рога и Копыта",
|
||||
* "address": "Москва, ул. Ленина 1",
|
||||
* "email": "info@example.com",
|
||||
* "website": "example.com",
|
||||
* "phone": "+7 999 123-45-67",
|
||||
* "inn": "7712345678",
|
||||
* "ogrn": "1234567890123",
|
||||
* "role": "Турагент" // опционально, для информации
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатами
|
||||
*/
|
||||
function vtws_upsertaccounts($offenders_json, $user = false) {
|
||||
$logFile = 'logs/UpsertAccounts.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($offenders_json, 0, 2000);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Очистка JSON от мусора (лишние кавычки, BOM, пробелы)
|
||||
$offenders_json = trim($offenders_json);
|
||||
$offenders_json = preg_replace('/^\xEF\xBB\xBF/', '', $offenders_json); // Убираем BOM
|
||||
|
||||
// Если строка обёрнута в кавычки — убираем
|
||||
if (preg_match('/^".*"$/s', $offenders_json)) {
|
||||
$offenders_json = substr($offenders_json, 1, -1);
|
||||
$offenders_json = stripcslashes($offenders_json); // Убираем экранирование
|
||||
}
|
||||
|
||||
// Убираем лишнюю кавычку в конце (баг n8n)
|
||||
$offenders_json = preg_replace('/"\s*$/', '', rtrim($offenders_json, '"'));
|
||||
if (substr($offenders_json, -1) !== ']' && substr($offenders_json, -1) !== '}') {
|
||||
// Пробуем найти конец массива/объекта
|
||||
if (($pos = strrpos($offenders_json, ']')) !== false) {
|
||||
$offenders_json = substr($offenders_json, 0, $pos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date("Y-m-d H:i:s") . ' CLEANED JSON: ' . substr($offenders_json, 0, 500);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Парсим JSON
|
||||
$offenders = json_decode($offenders_json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' RAW: ' . $offenders_json . PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
|
||||
}
|
||||
|
||||
if (!is_array($offenders)) {
|
||||
$offenders = [$offenders]; // Если передан один объект — оборачиваем в массив
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Получено offenders: ' . count($offenders);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Результаты
|
||||
$results = array(
|
||||
'success' => true,
|
||||
'total' => count($offenders),
|
||||
'created' => 0,
|
||||
'found' => 0,
|
||||
'errors' => 0,
|
||||
'accounts' => array()
|
||||
);
|
||||
|
||||
// Обрабатываем каждого offender
|
||||
foreach ($offenders as $index => $offender) {
|
||||
$accountResult = array(
|
||||
'index' => $index,
|
||||
'success' => false,
|
||||
'account_id' => null,
|
||||
'action' => null,
|
||||
'accountname' => $offender['accountname'] ?? '',
|
||||
'inn' => $offender['inn'] ?? '',
|
||||
'role' => $offender['role'] ?? null,
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
try {
|
||||
// Извлекаем данные
|
||||
$accountname = trim($offender['accountname'] ?? '');
|
||||
$address = trim($offender['address'] ?? '');
|
||||
$email = trim($offender['email'] ?? '');
|
||||
$website = trim($offender['website'] ?? '');
|
||||
$phone = trim($offender['phone'] ?? '');
|
||||
$inn = preg_replace('/[^0-9]/', '', $offender['inn'] ?? ''); // Только цифры
|
||||
$ogrn = preg_replace('/[^0-9]/', '', $offender['ogrn'] ?? ''); // Только цифры
|
||||
$role = trim($offender['role'] ?? '');
|
||||
|
||||
// Проверка обязательных полей
|
||||
if (empty($accountname)) {
|
||||
throw new Exception('Не указано наименование контрагента (accountname)');
|
||||
}
|
||||
if (empty($inn)) {
|
||||
throw new Exception('Не указан ИНН');
|
||||
}
|
||||
|
||||
// Валидация ИНН (10 или 12 цифр)
|
||||
if (strlen($inn) != 10 && strlen($inn) != 12) {
|
||||
$logstring = date('Y-m-d H:i:s') . " ⚠️ Нестандартный ИНН: $inn (длина " . strlen($inn) . ')';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
// Не падаем, просто логируем
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ПОИСК ПО ИНН
|
||||
// ========================================
|
||||
$query = "SELECT a.accountid, a.accountname
|
||||
FROM vtiger_account a
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
|
||||
WHERE e.deleted = 0 AND a.inn = ?
|
||||
LIMIT 1";
|
||||
$res = $adb->pquery($query, array($inn));
|
||||
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
// === НАЙДЕН — просто возвращаем ID ===
|
||||
$existingId = $adb->query_result($res, 0, 'accountid');
|
||||
$existingName = $adb->query_result($res, 0, 'accountname');
|
||||
|
||||
$accountResult['success'] = true;
|
||||
$accountResult['account_id'] = $existingId;
|
||||
$accountResult['action'] = 'found';
|
||||
$accountResult['message'] = 'Контрагент найден по ИНН';
|
||||
$accountResult['existing_name'] = $existingName;
|
||||
|
||||
$results['found']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ✓ [$index] Найден: $existingId ($existingName) по ИНН $inn";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === НЕ НАЙДЕН — создаём ===
|
||||
$params = array(
|
||||
'accountname' => $accountname,
|
||||
'bill_street' => $address,
|
||||
'email1' => $email,
|
||||
'website' => $website,
|
||||
'phone' => $phone,
|
||||
'inn' => $inn,
|
||||
'cf_1951' => $ogrn, // ОГРН в кастомном поле
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
|
||||
);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " 🆕 [$index] Создаём: " . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$account = vtws_create('Accounts', $params, $current_user);
|
||||
$newAccountId = substr($account['id'], 3); // Убираем 11x
|
||||
|
||||
$accountResult['success'] = true;
|
||||
$accountResult['account_id'] = $newAccountId;
|
||||
$accountResult['action'] = 'created';
|
||||
$accountResult['message'] = 'Контрагент создан';
|
||||
|
||||
$results['created']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ✅ [$index] Создан: $newAccountId";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$accountResult['success'] = false;
|
||||
$accountResult['message'] = $ex->getMessage();
|
||||
$results['errors']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] WebService ошибка: " . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} catch (Exception $ex) {
|
||||
$accountResult['success'] = false;
|
||||
$accountResult['message'] = $ex->getMessage();
|
||||
$results['errors']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] Ошибка: " . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
$results['accounts'][] = $accountResult;
|
||||
}
|
||||
|
||||
// Итоговый статус
|
||||
$results['success'] = ($results['errors'] == 0);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: total=' . $results['total']
|
||||
. ', created=' . $results['created']
|
||||
. ', found=' . $results['found']
|
||||
. ', errors=' . $results['errors'] . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($results, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
235
include/Webservices/UpsertContact.php
Normal file
235
include/Webservices/UpsertContact.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/обновления Контакта (Upsert)
|
||||
* Гибкий метод: обновляет если найден, создаёт если нет
|
||||
*
|
||||
* Приоритет поиска:
|
||||
* 1. contact_id (если передан - сразу обновляем)
|
||||
* 2. mobile (ищем по мобильному)
|
||||
* 3. tgid (ищем по полю phone, где хранится telegram_id)
|
||||
*
|
||||
* Все поля опциональны, кроме хотя бы одного идентификатора
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
include_once 'modules/Users/Users.php';
|
||||
require_once('include/Webservices/Utils.php');
|
||||
require_once 'include/Webservices/Create.php';
|
||||
require_once 'include/Webservices/Revise.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport('includes.runtime.Globals');
|
||||
vimport('includes.runtime.BaseModel');
|
||||
vimport('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Upsert контакта - создание или обновление
|
||||
*
|
||||
* @param string $contact_id - ID контакта в CRM (если известен)
|
||||
* @param string $mobile - мобильный телефон
|
||||
* @param string $tgid - telegram ID
|
||||
* @param string $firstname - имя
|
||||
* @param string $secondname - отчество
|
||||
* @param string $lastname - фамилия
|
||||
* @param string $email - email
|
||||
* @param string $birthday - дата рождения
|
||||
* @param string $birthplace - место рождения
|
||||
* @param string $mailingstreet - адрес
|
||||
* @param string $inn - ИНН
|
||||
* @param string $requisites - реквизиты
|
||||
* @param string $code - SMS код верификации
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатом
|
||||
*/
|
||||
function vtws_upsertcontact(
|
||||
$contact_id = '',
|
||||
$mobile = '',
|
||||
$tgid = '',
|
||||
$firstname = '',
|
||||
$secondname = '',
|
||||
$lastname = '',
|
||||
$email = '',
|
||||
$birthday = '',
|
||||
$birthplace = '',
|
||||
$mailingstreet = '',
|
||||
$inn = '',
|
||||
$requisites = '',
|
||||
$code = '',
|
||||
$user = false
|
||||
) {
|
||||
$logFile = 'logs/UpsertContact.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . json_encode($_REQUEST);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Результат
|
||||
$result = array(
|
||||
'success' => false,
|
||||
'contact_id' => null,
|
||||
'action' => null, // 'created', 'updated', 'found'
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 1. ФОРМАТИРОВАНИЕ ТЕЛЕФОНА
|
||||
// ========================================
|
||||
if (!empty($mobile)) {
|
||||
$mobile = preg_replace('/[^0-9]/', '', $mobile);
|
||||
if (strlen($mobile) == 11 && $mobile[0] == '8') {
|
||||
$mobile = "7" . substr($mobile, 1);
|
||||
} else if (strlen($mobile) == 10) {
|
||||
$mobile = "7" . $mobile;
|
||||
} else if (strlen($mobile) != 11) {
|
||||
// Некорректный номер - логируем, но не падаем
|
||||
$logstring = date("Y-m-d H:i:s") . ' ⚠️ Некорректный номер телефона: ' . $mobile . ' (игнорируем)';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
$mobile = ''; // Обнуляем некорректный номер
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. ПОИСК СУЩЕСТВУЮЩЕГО КОНТАКТА
|
||||
// ========================================
|
||||
$existingContactId = null;
|
||||
$searchMethod = '';
|
||||
|
||||
// 2.1 По contact_id (приоритет 1)
|
||||
if (!empty($contact_id)) {
|
||||
$contact_id = preg_replace('/[^0-9]/', '', $contact_id); // Очищаем от 12x префикса
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($contact_id));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_contact_id';
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 По mobile (приоритет 2)
|
||||
if (empty($existingContactId) && !empty($mobile)) {
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.mobile = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($mobile));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_mobile';
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 По tgid (приоритет 3) - tgid хранится в поле phone
|
||||
if (empty($existingContactId) && !empty($tgid)) {
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.phone = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($tgid));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_tgid';
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Поиск: contact_id=' . $contact_id . ', mobile=' . $mobile . ', tgid=' . $tgid;
|
||||
$logstring .= ' → Найден: ' . ($existingContactId ? $existingContactId . ' (' . $searchMethod . ')' : 'НЕТ');
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// ========================================
|
||||
// 3. ФОРМИРУЕМ ПАРАМЕТРЫ
|
||||
// ========================================
|
||||
$params = array();
|
||||
|
||||
// Только непустые поля добавляем в params
|
||||
if (!empty($firstname)) $params['firstname'] = $firstname;
|
||||
if (!empty($secondname)) $params['cf_1157'] = $secondname; // Отчество
|
||||
if (!empty($lastname)) $params['lastname'] = $lastname;
|
||||
if (!empty($mobile)) $params['mobile'] = $mobile;
|
||||
if (!empty($email)) $params['email'] = $email;
|
||||
if (!empty($tgid)) $params['phone'] = $tgid; // TG ID в поле phone
|
||||
if (!empty($birthday)) $params['birthday'] = $birthday;
|
||||
if (!empty($birthplace)) $params['cf_1263'] = $birthplace; // Место рождения
|
||||
if (!empty($mailingstreet)) $params['mailingstreet'] = $mailingstreet;
|
||||
if (!empty($inn)) $params['cf_1257'] = $inn; // ИНН
|
||||
if (!empty($requisites)) $params['cf_1849'] = $requisites; // Реквизиты
|
||||
if (!empty($code)) $params['cf_1580'] = $code; // SMS код
|
||||
|
||||
// ========================================
|
||||
// 4. СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
|
||||
// ========================================
|
||||
try {
|
||||
if (!empty($existingContactId)) {
|
||||
// === ОБНОВЛЕНИЕ ===
|
||||
$params['id'] = '12x' . $existingContactId;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем контакт ' . $existingContactId . ': ' . json_encode($params);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$contact = vtws_revise($params, $current_user);
|
||||
|
||||
$result['success'] = true;
|
||||
$result['contact_id'] = $existingContactId;
|
||||
$result['action'] = 'updated';
|
||||
$result['search_method'] = $searchMethod;
|
||||
$result['message'] = 'Контакт обновлён';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Контакт ' . $existingContactId . ' обновлён';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === СОЗДАНИЕ ===
|
||||
|
||||
// Проверяем минимальные данные для создания
|
||||
if (empty($mobile) && empty($tgid)) {
|
||||
throw new WebServiceException(
|
||||
WebServiceErrorCode::$INVALIDID,
|
||||
"Для создания контакта нужен хотя бы mobile или tgid"
|
||||
);
|
||||
}
|
||||
|
||||
// Дефолтные значения для обязательных полей CRM
|
||||
if (empty($params['firstname'])) {
|
||||
$params['firstname'] = 'Клиент';
|
||||
}
|
||||
if (empty($params['lastname'])) {
|
||||
$suffix = !empty($mobile) ? substr($mobile, -4) : substr($tgid, -4);
|
||||
$params['lastname'] = 'Web_' . $suffix;
|
||||
}
|
||||
if (empty($params['birthday'])) {
|
||||
$params['birthday'] = '01-01-1990';
|
||||
}
|
||||
|
||||
// Назначаем ответственного
|
||||
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём контакт: ' . json_encode($params);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$contact = vtws_create('Contacts', $params, $current_user);
|
||||
$newContactId = substr($contact['id'], 3); // Убираем 12x
|
||||
|
||||
$result['success'] = true;
|
||||
$result['contact_id'] = $newContactId;
|
||||
$result['action'] = 'created';
|
||||
$result['message'] = 'Контакт создан';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан контакт ' . $newContactId;
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw $ex;
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
297
include/Webservices/UpsertProject.php
Normal file
297
include/Webservices/UpsertProject.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/обновления Проекта (Upsert)
|
||||
*
|
||||
* Логика:
|
||||
* - Если передан project_id → обновляем существующий проект
|
||||
* - Если project_id не передан → создаём новый
|
||||
*
|
||||
* Принимает JSON с данными проекта
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
include_once 'include/Webservices/Query.php';
|
||||
include_once 'modules/Users/Users.php';
|
||||
require_once('include/Webservices/Utils.php');
|
||||
require_once 'include/Webservices/Create.php';
|
||||
require_once 'include/Webservices/Revise.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport('includes.runtime.Globals');
|
||||
vimport('includes.runtime.BaseModel');
|
||||
vimport('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Upsert проекта
|
||||
*
|
||||
* @param string $project_json - JSON с данными проекта:
|
||||
* {
|
||||
* "project_id": "12345", // Опционально - если есть, обновляем
|
||||
* "claim_id": "uuid", // ID заявки из PostgreSQL
|
||||
* "contact_id": "320096", // ID контакта (обязательно для создания)
|
||||
* "result": "JSON string", // Результат UpsertAccounts (парсится автоматически)
|
||||
* "offender_ids": ["390680"], // Альтернатива result - массив ID контрагентов
|
||||
* "projectdata": { // Данные проекта (cf_* поля)
|
||||
* "cf_2206": "SMS код",
|
||||
* "cf_1830": "категория",
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Контрагенты распределяются:
|
||||
* - accounts[0] → cf_2274 (основной ответчик)
|
||||
* - accounts[1] → cf_2276 (агент/второй ответчик)
|
||||
*
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатом
|
||||
*/
|
||||
function vtws_upsertproject($project_json, $user = false) {
|
||||
$logFile = 'logs/UpsertProject.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($project_json, 0, 2000);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Очистка JSON
|
||||
$project_json = trim($project_json);
|
||||
$project_json = preg_replace('/^\xEF\xBB\xBF/', '', $project_json);
|
||||
if (preg_match('/^".*"$/s', $project_json)) {
|
||||
$project_json = substr($project_json, 1, -1);
|
||||
$project_json = stripcslashes($project_json);
|
||||
}
|
||||
|
||||
// Парсим JSON
|
||||
$data = json_decode($project_json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
|
||||
}
|
||||
|
||||
// Результат
|
||||
$result = array(
|
||||
'success' => false,
|
||||
'project_id' => null,
|
||||
'claim_id' => null,
|
||||
'action' => null,
|
||||
'offender_id' => null,
|
||||
'agent_id' => null,
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
// Извлекаем данные
|
||||
$project_id = trim($data['project_id'] ?? '');
|
||||
$claim_id = trim($data['claim_id'] ?? '');
|
||||
$contact_id = trim($data['contact_id'] ?? '');
|
||||
$projectdata = $data['projectdata'] ?? [];
|
||||
|
||||
// Извлекаем контрагентов из result (если передан) или из offender_ids
|
||||
$offender_ids = [];
|
||||
|
||||
if (!empty($data['result'])) {
|
||||
// Парсим result от UpsertAccounts
|
||||
$accountsResult = $data['result'];
|
||||
if (is_string($accountsResult)) {
|
||||
$accountsResult = json_decode($accountsResult, true);
|
||||
}
|
||||
|
||||
// Извлекаем account_id из accounts[]
|
||||
if (isset($accountsResult['accounts']) && is_array($accountsResult['accounts'])) {
|
||||
foreach ($accountsResult['accounts'] as $account) {
|
||||
if (!empty($account['account_id'])) {
|
||||
$offender_ids[] = $account['account_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Извлечены offender_ids из result: ' . json_encode($offender_ids);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
} elseif (!empty($data['offender_ids'])) {
|
||||
$offender_ids = $data['offender_ids'];
|
||||
}
|
||||
|
||||
// cf_2274 = первый контрагент (основной ответчик)
|
||||
// cf_2276 = второй контрагент (агент/второй ответчик)
|
||||
$offender_id = count($offender_ids) > 0 ? $offender_ids[0] : '';
|
||||
$agent_id = count($offender_ids) > 1 ? $offender_ids[1] : '';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " Данные: project_id=$project_id, claim_id=$claim_id, contact_id=$contact_id, offender_id=$offender_id, agent_id=$agent_id";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
try {
|
||||
// ========================================
|
||||
// ПРОВЕРКА СУЩЕСТВОВАНИЯ ПРОЕКТА
|
||||
// ========================================
|
||||
$existingProjectId = null;
|
||||
|
||||
if (!empty($project_id)) {
|
||||
$project_id = preg_replace('/[^0-9]/', '', $project_id);
|
||||
$query = "SELECT p.projectid FROM vtiger_project p
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
|
||||
WHERE e.deleted = 0 AND p.projectid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($project_id));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingProjectId = $adb->query_result($res, 0, 'projectid');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ФОРМИРУЕМ ПАРАМЕТРЫ
|
||||
// ========================================
|
||||
$params = array();
|
||||
|
||||
// Если создаём новый проект - нужны contact_id и offender_id
|
||||
if (empty($existingProjectId)) {
|
||||
if (empty($contact_id) || empty($offender_id)) {
|
||||
throw new Exception('Для создания проекта нужны contact_id и offender_ids');
|
||||
}
|
||||
|
||||
// Получаем название контакта
|
||||
$query = "SELECT c.lastname FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($contact_id));
|
||||
$contactName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'lastname') : 'Клиент';
|
||||
|
||||
// Получаем название контрагента
|
||||
$query = "SELECT a.accountname FROM vtiger_account a
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
|
||||
WHERE e.deleted = 0 AND a.accountid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($offender_id));
|
||||
$accountName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'accountname') : 'Контрагент';
|
||||
|
||||
// Название проекта
|
||||
$params['projectname'] = $contactName . ' ' . $accountName;
|
||||
$params['linktoaccountscontacts'] = '12x' . $contact_id;
|
||||
$params['cf_2274'] = '11x' . $offender_id; // Основной ответчик
|
||||
$params['projectstatus'] = 'модерация';
|
||||
$params['projecttype'] = 'Претензионно - исковая работа';
|
||||
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
|
||||
|
||||
// Заявитель по умолчанию
|
||||
if (!isset($projectdata['cf_1994'])) {
|
||||
$params['cf_1994'] = vtws_getWebserviceEntityId('Accounts', 62345); // МОО КЛИЕНТПРАВ
|
||||
}
|
||||
}
|
||||
|
||||
// Агент (второй ответчик)
|
||||
if (!empty($agent_id)) {
|
||||
$params['cf_2276'] = '11x' . $agent_id;
|
||||
}
|
||||
|
||||
// Связь контакт/оффендер для обновления тоже можно передать
|
||||
if (!empty($contact_id) && !empty($existingProjectId)) {
|
||||
$params['linktoaccountscontacts'] = '12x' . $contact_id;
|
||||
}
|
||||
if (!empty($offender_id) && !empty($existingProjectId)) {
|
||||
$params['cf_2274'] = '11x' . $offender_id;
|
||||
}
|
||||
|
||||
// Маппинг полей из projectdata
|
||||
$fieldMapping = array(
|
||||
'cf_2206' => 'cf_2206', // SMS код
|
||||
'cf_2210' => 'cf_2210', // IP
|
||||
'cf_2212' => 'cf_2212', // Источник
|
||||
'cf_2214' => 'cf_2214', // Регион
|
||||
'cf_2208' => 'cf_2208', // Form ID
|
||||
'cf_1830' => 'cf_1830', // Категория
|
||||
'cf_1469' => 'cf_1469', // Направление
|
||||
'cf_1191' => 'cf_1191', // Цена договора
|
||||
'cf_1189' => 'cf_1189', // Предмет договора
|
||||
'cf_1203' => 'cf_1203', // Дата договора
|
||||
'cf_1839' => 'cf_1839', // Дата начала
|
||||
'cf_1841' => 'cf_1841', // Дата окончания
|
||||
'cf_1207' => 'cf_1207', // Ущерб
|
||||
'cf_1479' => 'cf_1479', // Стоимость услуг
|
||||
'cf_1227' => 'cf_1227', // Прогресс
|
||||
'cf_1231' => 'cf_1231', // Страна
|
||||
'cf_1239' => 'cf_1239', // Отель
|
||||
'cf_1566' => 'cf_1566', // Транспорт
|
||||
'cf_1564' => 'cf_1564', // Страховка
|
||||
'cf_1249' => 'cf_1249', // Прочее
|
||||
'cf_1471' => 'cf_1471', // Самостоятельно
|
||||
'cf_1473' => 'cf_1473', // Дата претензии
|
||||
'cf_1475' => 'cf_1475', // Возвращено
|
||||
'cf_1994' => 'cf_1994', // Заявитель
|
||||
'description' => 'description'
|
||||
);
|
||||
|
||||
foreach ($fieldMapping as $input => $crm) {
|
||||
if (isset($projectdata[$input]) && $projectdata[$input] !== null) {
|
||||
$value = $projectdata[$input];
|
||||
// Для cf_1994 (Заявитель) нужен формат 11xID
|
||||
if ($crm === 'cf_1994' && !empty($value) && strpos($value, 'x') === false) {
|
||||
$value = vtws_getWebserviceEntityId('Accounts', $value);
|
||||
}
|
||||
$params[$crm] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
|
||||
// ========================================
|
||||
if (!empty($existingProjectId)) {
|
||||
// === ОБНОВЛЕНИЕ ===
|
||||
$params['id'] = '33x' . $existingProjectId; // 33x для Project
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем проект ' . $existingProjectId . ': ' . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$project = vtws_revise($params, $current_user);
|
||||
|
||||
$result['success'] = true;
|
||||
$result['project_id'] = $existingProjectId;
|
||||
$result['claim_id'] = $claim_id;
|
||||
$result['action'] = 'updated';
|
||||
$result['offender_id'] = $offender_id;
|
||||
$result['agent_id'] = $agent_id ?: null;
|
||||
$result['message'] = 'Проект обновлён';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Проект ' . $existingProjectId . ' обновлён';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === СОЗДАНИЕ ===
|
||||
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём проект: ' . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$project = vtws_create('Project', $params, $current_user);
|
||||
$newProjectId = substr($project['id'], strpos($project['id'], 'x') + 1); // Убираем префикс (63x)
|
||||
|
||||
$result['success'] = true;
|
||||
$result['project_id'] = $newProjectId;
|
||||
$result['claim_id'] = $claim_id;
|
||||
$result['action'] = 'created';
|
||||
$result['offender_id'] = $offender_id;
|
||||
$result['agent_id'] = $agent_id ?: null;
|
||||
$result['message'] = 'Проект создан';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан проект ' . $newProjectId;
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ WebService ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw $ex;
|
||||
|
||||
} catch (Exception $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $ex->getMessage());
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
24
languages/en_us/OnlyOfficeTemplates.php
Normal file
24
languages/en_us/OnlyOfficeTemplates.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
$languageStrings = [
|
||||
'LBL_OOT_TEMPLATES' => 'Document templates',
|
||||
'LBL_OOT_SELECT_TEMPLATE' => 'Template',
|
||||
'LBL_OOT_FORMAT' => 'Format',
|
||||
'LBL_OOT_FORMAT_PDF' => 'PDF',
|
||||
'LBL_OOT_FORMAT_DOCX' => 'DOCX',
|
||||
'LBL_OOT_DOWNLOAD' => 'Download',
|
||||
'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Save to Documents',
|
||||
'LBL_OOT_NO_TEMPLATES' => 'No templates for this module',
|
||||
'LBL_OOT_EMPTY_LIST' => 'No templates found',
|
||||
'LBL_OOT_NAME' => 'Name',
|
||||
'LBL_OOT_MODULE' => 'Module',
|
||||
'LBL_OOT_FILE' => 'File',
|
||||
'LBL_OOT_CREATED_AT' => 'Created',
|
||||
'LBL_OOT_ADD_TEMPLATE' => 'Add template',
|
||||
'LBL_OOT_FILE_HINT' => 'DOCX files only',
|
||||
'LBL_OOT_EDIT_TEMPLATE' => 'Edit template',
|
||||
'LBL_OOT_NEW_TEMPLATE' => 'New template',
|
||||
'LBL_OOT_EDITOR_HINT' => 'Edit the document on the right. Saving to S3 happens automatically when closing or when clicking Save in the editor.',
|
||||
'LBL_OOT_EDITOR_FALLBACK' => 'If OnlyOffice is not configured, you can upload a DOCX file instead.',
|
||||
'LBL_OOT_ADD_VIA_UPLOAD' => 'Upload DOCX file',
|
||||
'LBL_OOT_UPLOAD_FILE' => 'Upload file',
|
||||
];
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
24
languages/ru_ru/OnlyOfficeTemplates.php
Normal file
24
languages/ru_ru/OnlyOfficeTemplates.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
$languageStrings = [
|
||||
'LBL_OOT_TEMPLATES' => 'Шаблоны документов',
|
||||
'LBL_OOT_SELECT_TEMPLATE' => 'Шаблон',
|
||||
'LBL_OOT_FORMAT' => 'Формат',
|
||||
'LBL_OOT_FORMAT_PDF' => 'PDF',
|
||||
'LBL_OOT_FORMAT_DOCX' => 'DOCX',
|
||||
'LBL_OOT_DOWNLOAD' => 'Скачать',
|
||||
'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Сохранить в Документы',
|
||||
'LBL_OOT_NO_TEMPLATES' => 'Нет шаблонов для этого модуля',
|
||||
'LBL_OOT_EMPTY_LIST' => 'Шаблоны не найдены',
|
||||
'LBL_OOT_NAME' => 'Название',
|
||||
'LBL_OOT_MODULE' => 'Модуль',
|
||||
'LBL_OOT_FILE' => 'Файл',
|
||||
'LBL_OOT_CREATED_AT' => 'Создан',
|
||||
'LBL_OOT_ADD_TEMPLATE' => 'Добавить шаблон',
|
||||
'LBL_OOT_FILE_HINT' => 'Только файлы DOCX',
|
||||
'LBL_OOT_EDIT_TEMPLATE' => 'Редактирование шаблона',
|
||||
'LBL_OOT_NEW_TEMPLATE' => 'Новый шаблон',
|
||||
'LBL_OOT_EDITOR_HINT' => 'Редактируйте документ справа. Сохранение в S3 происходит автоматически при закрытии или по кнопке «Сохранить» в редакторе.',
|
||||
'LBL_OOT_EDITOR_FALLBACK' => 'Если OnlyOffice не настроен, можно загрузить готовый DOCX-файл.',
|
||||
'LBL_OOT_ADD_VIA_UPLOAD' => 'Загрузить DOCX-файл',
|
||||
'LBL_OOT_UPLOAD_FILE' => 'Загрузить файл',
|
||||
];
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -53,4 +53,5 @@ $languageStrings = array(
|
||||
'заявление на лист' => 'заявление на лист',
|
||||
'урегулирование' => 'урегулирование',
|
||||
'заключение мирового соглашения' => 'заключение мирового соглашения',
|
||||
'Черновик' => 'Черновик',
|
||||
);
|
||||
@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
|
||||
projectName = projectName.replace(/"/g, '_');
|
||||
// Заменяем ВСЕ пробелы на подчёркивания
|
||||
projectName = projectName.replace(/\s+/g, '_');
|
||||
// Заменяем множественные подчёркивания на одинарное
|
||||
projectName = projectName.replace(/_+/g, '_');
|
||||
// Убираем подчёркивания в начале и конце
|
||||
projectName = projectName.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
// Формируем URL для папки проекта в Nextcloud
|
||||
|
||||
@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
|
||||
projectName = projectName.replace(/"/g, '_');
|
||||
// Заменяем ВСЕ пробелы на подчёркивания
|
||||
projectName = projectName.replace(/\s+/g, '_');
|
||||
// Заменяем множественные подчёркивания на одинарное
|
||||
projectName = projectName.replace(/_+/g, '_');
|
||||
// Убираем подчёркивания в начале и конце
|
||||
projectName = projectName.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
// Формируем URL для папки проекта в Nextcloud
|
||||
@@ -448,24 +452,37 @@ function editInNextcloud(recordId, fileName) {
|
||||
|
||||
/**
|
||||
* Открытие файла через Nextcloud Files UI (с версионированием)
|
||||
* Использует Redis индекс для быстрого получения FileID
|
||||
*/
|
||||
function openViaNextcloud(recordId, fileName) {
|
||||
console.log('📚 Opening via Nextcloud Files UI:', recordId, fileName);
|
||||
console.log('📚 Opening via Nextcloud Files UI:', recordId);
|
||||
|
||||
// Открываем через nextcloud_open.php (PROPFIND → Nextcloud UI)
|
||||
const redirectUrl = `/crm_extensions/file_storage/api/nextcloud_open.php?recordId=${recordId}&fileName=${encodeURIComponent(fileName)}&v=${Date.now()}`;
|
||||
|
||||
console.log('🎯 Opening via Nextcloud:', redirectUrl);
|
||||
|
||||
// Открываем в новом окне
|
||||
const win = window.open(redirectUrl, 'nextcloud_files_' + Date.now(), 'width=1400,height=900,scrollbars=yes,resizable=yes');
|
||||
|
||||
if (win) {
|
||||
console.log('✅ Nextcloud opened successfully');
|
||||
} else {
|
||||
console.log('❌ Failed to open window - popup blocked');
|
||||
alert('❌ Не удалось открыть Nextcloud. Проверьте блокировку всплывающих окон.');
|
||||
}
|
||||
// Получаем FileID и redirect URL из API
|
||||
fetch(`/crm_extensions/file_storage/api/nextcloud_open.php?recordId=${recordId}&v=${Date.now()}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('✅ FileID получен:', data.fileId);
|
||||
console.log('🔗 Redirect URL:', data.redirectUrl);
|
||||
|
||||
// Открываем Nextcloud в новом окне
|
||||
const win = window.open(data.redirectUrl, 'nextcloud_files_' + Date.now(), 'width=1400,height=900,scrollbars=yes,resizable=yes');
|
||||
|
||||
if (win) {
|
||||
console.log('✅ Nextcloud opened successfully');
|
||||
} else {
|
||||
console.log('❌ Failed to open window - popup blocked');
|
||||
alert('❌ Не удалось открыть Nextcloud. Проверьте блокировку всплывающих окон.');
|
||||
}
|
||||
} else {
|
||||
console.error('❌ API error:', data);
|
||||
alert('❌ Ошибка получения FileID: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Fetch error:', error);
|
||||
alert('❌ Ошибка подключения к API');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
<div class="info-row">
|
||||
<i class="fa fa-map-marker"></i>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
<div class="info-row">
|
||||
<i class="fa fa-map-marker"></i>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
{assign var=PRIORITY value=$RECORD->get('ticketpriorities')}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
<div class="row info-row">
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
<div class="info-row row">
|
||||
|
||||
54
layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl
Normal file
54
layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl
Normal file
@@ -0,0 +1,54 @@
|
||||
{strip}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<h4 class="pull-left">{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}</h4>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
{if $ERROR_MSG}
|
||||
<div class="alert alert-danger">{$ERROR_MSG|escape}</div>
|
||||
{/if}
|
||||
<form action="index.php" method="post" enctype="multipart/form-data" class="form-horizontal">
|
||||
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
|
||||
<input type="hidden" name="action" value="UploadTemplate" />
|
||||
<input type="hidden" name="redirect" value="List" />
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)} <span class="redColor">*</span></label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" name="name" class="form-control" required="required" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)} <span class="redColor">*</span></label>
|
||||
<div class="col-sm-9">
|
||||
<select name="module_name" class="form-control" required="required">
|
||||
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
|
||||
{foreach from=$MODULES key=modName item=modLabel}
|
||||
<option value="{$modName}">{$modLabel}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_FILE', $MODULE_NAME)} (DOCX) <span class="redColor">*</span></label>
|
||||
<div class="col-sm-9">
|
||||
<input type="file" name="file" accept=".docx" required="required" />
|
||||
<span class="help-block">{vtranslate('LBL_OOT_FILE_HINT', $MODULE_NAME)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-3 col-sm-9">
|
||||
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/strip}
|
||||
76
layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl
Normal file
76
layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl
Normal file
@@ -0,0 +1,76 @@
|
||||
{strip}
|
||||
<div class="contents tabbable">
|
||||
<div class="row">
|
||||
<div class="col-xs-4 left-block" style="border-right: 1px solid #ddd;">
|
||||
<h4>{if $TEMPLATE.id gt 0}{vtranslate('LBL_OOT_EDIT_TEMPLATE', $MODULE_NAME)}{else}{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}{/if}</h4>
|
||||
{if $ERROR_MSG}<div class="alert alert-danger">{$ERROR_MSG|escape}</div>{/if}
|
||||
<form id="ootMetadataForm" action="index.php" method="post" class="form-horizontal">
|
||||
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
|
||||
<input type="hidden" name="action" value="SaveMetadata" />
|
||||
<input type="hidden" name="templateid" value="{$TEMPLATE.id}" />
|
||||
<input type="hidden" name="redirect" value="Edit" />
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" name="name" class="form-control" value="{$TEMPLATE.name|escape}" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</label>
|
||||
<div class="col-sm-8">
|
||||
<select name="module_name" class="form-control" required>
|
||||
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
|
||||
{foreach from=$MODULES key=modName item=modLabel}
|
||||
<option value="{$modName}" {if $TEMPLATE.module eq $modName}selected{/if}>{$modLabel}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-4 col-sm-8">
|
||||
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-xs-8" style="min-height: 600px;">
|
||||
{if $OOT_EDITOR_AVAILABLE}
|
||||
<p class="text-muted">{vtranslate('LBL_OOT_EDITOR_HINT', $MODULE_NAME)}</p>
|
||||
<div id="ootOnlyOfficeEditor" style="width:100%; height:700px;"></div>
|
||||
<script src="{$OOT_DOCUMENT_SERVER}/web-apps/apps/api/documents/api.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var docKey = "{$OOT_DOC_KEY|escape:'javascript'}";
|
||||
var config = {
|
||||
document: {
|
||||
fileType: "docx",
|
||||
key: docKey,
|
||||
title: "{$OOT_DOC_TITLE|escape:'javascript'}",
|
||||
url: "{$OOT_DOCUMENT_URL|escape:'javascript'}"
|
||||
},
|
||||
documentType: "word",
|
||||
editorConfig: {
|
||||
callbackUrl: "{$OOT_CALLBACK_URL|escape:'javascript'}",
|
||||
mode: "edit",
|
||||
lang: "ru"
|
||||
},
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
};
|
||||
if (typeof DocsAPI !== "undefined") {
|
||||
new DocsAPI.DocEditor("ootOnlyOfficeEditor", config);
|
||||
} else {
|
||||
document.getElementById("ootOnlyOfficeEditor").innerHTML = "<div class=\"alert alert-warning\">Не удалось загрузить OnlyOffice. Проверьте ONLYOFFICE_DOCUMENT_SERVER.</div>";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{else}
|
||||
<div class="alert alert-info">{$OOT_EDITOR_MESSAGE|escape}</div>
|
||||
<p>{vtranslate('LBL_OOT_EDITOR_FALLBACK', $MODULE_NAME)}</p>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-primary">{vtranslate('LBL_OOT_ADD_VIA_UPLOAD', $MODULE_NAME)}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/strip}
|
||||
@@ -0,0 +1,65 @@
|
||||
{*
|
||||
OnlyOfficeTemplates widget: template list, format (PDF/DOCX), Download / Save to Documents
|
||||
*}
|
||||
{if $CRM_TEMPLATES_EXIST eq 0}
|
||||
<li class="dropdown-header">
|
||||
<span class="fa fa-file-text-o"></span> {vtranslate('LBL_OOT_TEMPLATES','OnlyOfficeTemplates')}
|
||||
</li>
|
||||
<li class="oot-widget" data-record="{$ID}" data-module="{$MODULE}">
|
||||
<div class="form-group">
|
||||
<label class="control-label">{vtranslate('LBL_OOT_SELECT_TEMPLATE','OnlyOfficeTemplates')}</label>
|
||||
<select class="oot-template-id form-control input-sm">
|
||||
{foreach from=$CRM_TEMPLATES item=tpl}
|
||||
<option value="{$tpl.id}">{$tpl.name}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{vtranslate('LBL_OOT_FORMAT','OnlyOfficeTemplates')}</label>
|
||||
<select class="oot-format form-control input-sm">
|
||||
<option value="pdf">{vtranslate('LBL_OOT_FORMAT_PDF','OnlyOfficeTemplates')}</option>
|
||||
<option value="docx">{vtranslate('LBL_OOT_FORMAT_DOCX','OnlyOfficeTemplates')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="btn-group btn-group-justified">
|
||||
<a href="javascript:;" class="oot-download"><i class="fa fa-download"></i> {vtranslate('LBL_OOT_DOWNLOAD','OnlyOfficeTemplates')}</a>
|
||||
<a href="javascript:;" class="oot-save-to-docs"><i class="fa fa-save"></i> {vtranslate('LBL_OOT_SAVE_TO_DOCUMENTS','OnlyOfficeTemplates')}</a>
|
||||
</div>
|
||||
</li>
|
||||
{else}
|
||||
<li><span class="text-muted">{vtranslate('LBL_OOT_NO_TEMPLATES','OnlyOfficeTemplates')}</span></li>
|
||||
{/if}
|
||||
{if $CRM_TEMPLATES_EXIST eq 0}
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
var container = document.querySelector('.oot-widget');
|
||||
if (!container) return;
|
||||
container.addEventListener('click', function(e) {
|
||||
var t = e.target.closest('.oot-download, .oot-save-to-docs');
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
var record = container.getAttribute('data-record'), module = container.getAttribute('data-module');
|
||||
var sel = container.querySelector('.oot-template-id'), fmt = container.querySelector('.oot-format');
|
||||
var templateId = sel ? sel.value : '', format = fmt ? fmt.value : 'pdf';
|
||||
var mode = t.classList.contains('oot-download') ? 'download' : 'save_to_documents';
|
||||
var url = 'index.php?module=OnlyOfficeTemplates&action=CreateFromTemplate&record=' + encodeURIComponent(record) + '&source_module=' + encodeURIComponent(module) + '&template_id=' + encodeURIComponent(templateId) + '&format=' + encodeURIComponent(format) + '&mode=' + encodeURIComponent(mode);
|
||||
if (mode === 'download') {
|
||||
window.location.href = url;
|
||||
} else {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url + '&ajax=1');
|
||||
xhr.onload = function() {
|
||||
var res = {};
|
||||
try { res = JSON.parse(xhr.responseText); } catch (err) {}
|
||||
if (res.success) {
|
||||
alert(res.message || 'Сохранено в Документы');
|
||||
} else {
|
||||
alert(res.error || 'Ошибка');
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{/if}
|
||||
47
layouts/v7/modules/OnlyOfficeTemplates/List.tpl
Normal file
47
layouts/v7/modules/OnlyOfficeTemplates/List.tpl
Normal file
@@ -0,0 +1,47 @@
|
||||
{strip}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<h4 class="pull-left">{vtranslate('LBL_OOT_TEMPLATES', $MODULE_NAME)}</h4>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&action=CreateDraft&app=TOOLS" class="btn btn-success pull-right">
|
||||
<i class="fa fa-plus"></i> {vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}
|
||||
</a>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-default pull-right" style="margin-right:8px;">
|
||||
{vtranslate('LBL_OOT_UPLOAD_FILE', $MODULE_NAME)}
|
||||
</a>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
{if empty($TEMPLATES)}
|
||||
<div class="alert alert-info">{vtranslate('LBL_OOT_EMPTY_LIST', $MODULE_NAME)}</div>
|
||||
{else}
|
||||
<table class="table table-bordered table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</th>
|
||||
<th>{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</th>
|
||||
<th>{vtranslate('LBL_OOT_FILE', $MODULE_NAME)}</th>
|
||||
<th>{vtranslate('LBL_OOT_CREATED_AT', $MODULE_NAME)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach from=$TEMPLATES item=TPL}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="index.php?module=OnlyOfficeTemplates&view=Edit&templateid={$TPL.id}&app=TOOLS">{$TPL.name|escape}</a>
|
||||
</td>
|
||||
<td>{$TPL.module|escape}</td>
|
||||
<td>{$TPL.file_name|escape}</td>
|
||||
<td>{$TPL.created_at|escape}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/strip}
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
{assign var=RELATED_TO value=$RECORD->get('related_to')}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
{assign var=RELATED_TO value=$RECORD->get('linktoaccountscontacts')}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
<div class="info-row row">
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
<div class="row info-row">
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
{* Подключаем Nextcloud Editor JS *}
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js"></script>
|
||||
<script type="text/javascript" src="crm_extensions/nextcloud_editor/js/nextcloud-editor.js?v=20251102c"></script>
|
||||
|
||||
{*
|
||||
<div class="row info-row">
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
/* AI Drawer - основные стили */
|
||||
.ai-drawer {
|
||||
position: fixed;
|
||||
right: -400px; /* Начально скрыт */
|
||||
right: 0; /* Всегда прижат к правому краю */
|
||||
top: 0;
|
||||
width: 400px;
|
||||
min-width: 300px; /* Минимальная ширина */
|
||||
max-width: 50vw; /* Максимальная ширина - половина экрана */
|
||||
height: 100vh;
|
||||
max-height: 100vh; /* Не превышаем высоту экрана */
|
||||
background: #ffffff; /* Чистый белый фон */
|
||||
box-shadow: -2px 0 15px rgba(0,0,0,0.1);
|
||||
transition: right 0.3s ease;
|
||||
transform: translateX(100%); /* Начально скрыт - сдвинут вправо на 100% своей ширины */
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px; /* Базовый размер шрифта */
|
||||
border-left: 1px solid #e9ecef;
|
||||
overflow: hidden; /* Предотвращаем выход элементов за пределы */
|
||||
box-sizing: border-box; /* Учитываем padding и border в ширине */
|
||||
}
|
||||
|
||||
/* Полоска для изменения ширины */
|
||||
.ai-drawer-resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
background: transparent;
|
||||
z-index: 1000000;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-drawer-resize-handle:hover {
|
||||
background: #007bff;
|
||||
}
|
||||
|
||||
.ai-drawer-resize-handle:active {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* Визуальная индикация при перетаскивании */
|
||||
.ai-drawer.resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Убираем transition при изменении размера, чтобы не было задержек */
|
||||
.ai-drawer.resizing.open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
.ai-drawer.resizing .ai-drawer-resize-handle {
|
||||
background: #007bff;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.ai-drawer.open {
|
||||
right: 0;
|
||||
transform: translateX(0); /* Показываем - сдвигаем на место */
|
||||
}
|
||||
|
||||
/* Скрываем кнопку AI когда drawer открыт */
|
||||
@@ -57,6 +100,9 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #0056b3;
|
||||
flex-shrink: 0; /* Не сжимается при изменении размера */
|
||||
min-height: 50px; /* Минимальная высота для кнопки закрытия */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ai-drawer-close {
|
||||
@@ -192,6 +238,11 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
margin-right: 400px;
|
||||
}
|
||||
|
||||
/* Динамический margin для main-container при изменении ширины drawer */
|
||||
.main-container.drawer-open[data-drawer-width] {
|
||||
margin-right: var(--drawer-width, 400px);
|
||||
}
|
||||
|
||||
/* Плавающий индикатор загрузки */
|
||||
.ai-loading-overlay {
|
||||
position: fixed;
|
||||
@@ -397,12 +448,42 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
|
||||
.ai-message-content p {
|
||||
margin: 0 0 5px 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Стили для ссылок в сообщениях */
|
||||
.ai-message-link {
|
||||
color: #007bff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
word-break: break-word;
|
||||
display: inline-block;
|
||||
margin: 2px 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-message-link:hover {
|
||||
color: #0056b3;
|
||||
text-decoration: none;
|
||||
background-color: #e7f3ff;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.ai-message-link:visited {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.ai-message-link:active {
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.ai-message-time {
|
||||
font-size: 11px;
|
||||
color: #6c757d; /* Серый цвет для времени */
|
||||
@@ -417,6 +498,8 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0; /* Не сжимается при изменении размера */
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -471,11 +554,19 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
@media (max-width: 768px) {
|
||||
.ai-drawer {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
right: 0;
|
||||
transform: translateX(100%); /* Начально скрыт на мобильных */
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Динамическая высота viewport для мобильных */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Скрываем resize handle на мобильных */
|
||||
.ai-drawer-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-container.drawer-open {
|
||||
@@ -683,7 +774,10 @@ body.ai-drawer-open .ai-drawer-toggle {
|
||||
/* Десктопная версия drawer */
|
||||
.ai-drawer {
|
||||
width: 400px;
|
||||
right: -400px;
|
||||
min-width: 300px;
|
||||
max-width: 50vw;
|
||||
right: 0;
|
||||
transform: translateX(100%); /* Начально скрыт на планшетах */
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user