From: Aleksey Filippov Date: Tue, 3 Feb 2026 20:57:32 +0000 (+0300) Subject: [ERP-43-J] Уедомление, напоминание о закрытии и открытии смены. X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=90b42bee398f4574d416c713e56b46ea6c992dd9;p=erp24_rep%2Fyii-erp24%2F.git [ERP-43-J] Уедомление, напоминание о закрытии и открытии смены. --- diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md new file mode 100644 index 00000000..4823064b --- /dev/null +++ b/.claude/skills/changelog/SKILL.md @@ -0,0 +1,377 @@ +--- +name: changelog +description: Генерирует summary изменений из git diff/log. Используй когда нужно создать CHANGELOG запись, получить обзор изменений, подготовить release notes или описание для PR. +--- + +# Changelog Generator + +Генерирует детальное структурированное summary изменений с цветовым выделением. + +## ⚡ ПЕРВЫЙ ШАГ: Запрос разрешений + +**ОБЯЗАТЕЛЬНО** перед началом работы запроси у пользователя разрешение на выполнение команд. + +### Шаблон запроса разрешений + +Используй `AskUserQuestion` tool с таким форматом: + +```text +Для генерации changelog мне потребуется выполнить следующие команды: + +📋 **Git команды (только чтение):** +• `git status` — статус рабочей директории +• `git diff --stat HEAD` — статистика изменений +• `git diff --name-status HEAD` — список изменённых файлов +• `git diff HEAD` — содержимое изменений +• `git log --oneline -10` — последние коммиты +• `git branch --show-current` — текущая ветка + +📁 **Анализ файлов:** +• Чтение изменённых файлов для понимания контекста + +Разрешить выполнение? +``` + +### Варианты ответа пользователя + +Предложи варианты через `AskUserQuestion`: + +| Вариант | Описание | +| ------- | -------- | +| Разрешить все | Выполнить полный анализ | +| Только статус | Минимальный отчёт (git status + diff --stat) | +| Показать команды | Вывести команды без выполнения | + +### Пример использования AskUserQuestion + +```json +{ + "questions": [{ + "question": "Какой уровень анализа выполнить для changelog?", + "header": "Changelog", + "options": [ + { + "label": "Полный анализ (Рекомендуется)", + "description": "git status, diff, log + анализ содержимого файлов" + }, + { + "label": "Быстрый отчёт", + "description": "Только git status и diff --stat" + }, + { + "label": "Показать план", + "description": "Показать какие команды будут выполнены" + } + ], + "multiSelect": false + }] +} +``` + +### После получения разрешения + +1. **Полный анализ** → выполнить все команды из секции "Команды для сбора данных" +2. **Быстрый отчёт** → только `git status -s` и `git diff --stat HEAD` +3. **Показать план** → вывести список команд и спросить повторно + +--- + +## Кликабельные ссылки на файлы + +**ОБЯЗАТЕЛЬНО** все пути к файлам оформляй как markdown-ссылки для IDE: + +### Формат ссылок + +```markdown +# Файл (кликабельный) +[filename.php](path/to/filename.php) + +# Файл с номером строки +[filename.php:42](path/to/filename.php#L42) + +# Диапазон строк +[filename.php:10-25](path/to/filename.php#L10-L25) + +# Папка +[controllers/](controllers/) +``` + +### Примеры в changelog + +```text +🟡 ИЗМЕНЕНО +─────────────────────────────────────────────────────────────── +~ [OrderController.php](erp24/controllers/OrderController.php) [+45 -12] + └─ Новые методы: + • [actionExport():52](erp24/controllers/OrderController.php#L52) + • [actionBulkUpdate():78](erp24/controllers/OrderController.php#L78) +``` + +### Правила форматирования ссылок + +| Элемент | Формат | Пример | +| ------- | ------ | ------ | +| Изменённый файл | `[name](path)` | `[User.php](models/User.php)` | +| Новый метод | `[method:line](path#L)` | `[save():45](models/User.php#L45)` | +| Удалённый файл | `~~[name](path)~~` | `~~[Old.php](old/Old.php)~~` | +| Папка | `[name/](path/)` | `[services/](app/services/)` | + +--- + +## Цветовая схема вывода + +При выводе summary ОБЯЗАТЕЛЬНО используй цветовое выделение: + +| Тип | Цвет/Формат | Пример | +| --- | ----------- | ------ | +| Добавлено | 🟢 **зелёный** | `+ новый файл` | +| Изменено | 🟡 **жёлтый** | `~ модификация` | +| Удалено | 🔴 **красный** | `- удалённый файл` | +| Переименовано | 🔵 **синий** | `→ старое → новое` | +| Критично | ⚠️ **предупреждение** | Миграции, безопасность | +| Статистика | 📊 **инфо** | Строки, покрытие | + +## Формат детального вывода + +### Шаблон summary + +```text +═══════════════════════════════════════════════════════════════ + 📋 SUMMARY ИЗМЕНЕНИЙ +═══════════════════════════════════════════════════════════════ + +📅 Дата: YYYY-MM-DD HH:MM +🌿 Ветка: feature/xxx → develop +👤 Автор: name + +─────────────────────────────────────────────────────────────── +🟢 ДОБАВЛЕНО (N файлов) +─────────────────────────────────────────────────────────────── ++ [new-file.php](path/to/new-file.php) [+150 строк] + └─ Описание: Новый сервис для обработки заказов + └─ Классы: OrderProcessingService + └─ Методы: [process():15](path/to/new-file.php#L15), validate(), notify() + ++ [another-file.ts](path/to/another-file.ts) [+80 строк] + └─ Описание: React компонент формы + └─ Компоненты: OrderForm, OrderFormProps + +─────────────────────────────────────────────────────────────── +🟡 ИЗМЕНЕНО (N файлов) +─────────────────────────────────────────────────────────────── +~ [modified-file.php](path/to/modified-file.php) [+50 -20 строк] + └─ Что изменено: + • Добавлен [calculateDiscount():42](path/to/modified-file.php#L42) + • Рефакторинг [processOrder():78](path/to/modified-file.php#L78) + • Обновлены типы параметров + └─ Затронутые методы: calculate(), process(), validate() + +~ [config.php](path/to/config.php) [+5 -2 строк] + └─ Что изменено: + • Добавлен новый параметр 'cache_ttl' + • Изменено значение 'timeout': 30 → 60 + +─────────────────────────────────────────────────────────────── +🔴 УДАЛЕНО (N файлов) +─────────────────────────────────────────────────────────────── +- ~~[deprecated-file.php](path/to/deprecated-file.php)~~ [-200 строк] + └─ Причина: Заменён на NewService + └─ Зависимости: Проверить [Controller.php](path/to/Controller.php) + +─────────────────────────────────────────────────────────────── +🔵 ПЕРЕИМЕНОВАНО (N файлов) +─────────────────────────────────────────────────────────────── +→ [old-name.php](old-name.php) → [new-name.php](new-name.php) + └─ Причина: Соответствие PSR-4 + +─────────────────────────────────────────────────────────────── +⚠️ ТРЕБУЕТ ВНИМАНИЯ +─────────────────────────────────────────────────────────────── +• 🗄️ Миграции: m240115_120000_add_orders_table.php + └─ Действие: php yii migrate + └─ Откат: php yii migrate/down 1 + +• 🔐 Безопасность: изменения в auth/ + └─ Проверить: права доступа, токены + +• ⚙️ Конфигурация: .env.example обновлён + └─ Добавить: NEW_API_KEY в .env + +─────────────────────────────────────────────────────────────── +📊 СТАТИСТИКА +─────────────────────────────────────────────────────────────── +Файлов изменено: 12 +Строк добавлено: +1,250 🟢 +Строк удалено: -340 🔴 +Чистый прирост: +910 + +По категориям: + Controllers: 3 файла (+200 -50) + Models: 4 файла (+400 -100) + Services: 2 файла (+300 -90) + Views: 2 файла (+250 -80) + Tests: 1 файл (+100 -20) + +═══════════════════════════════════════════════════════════════ +``` + +## Команды для сбора данных + +### 1. Детальная информация об изменениях + +```bash +# Статистика по файлам с цветом +git diff --stat --color HEAD + +# Подробный diff для анализа ЧТО изменилось +git diff HEAD -- "*.php" | head -100 + +# Изменённые функции/методы (для PHP) +git diff HEAD --function-context -- "*.php" + +# Список всех изменений с типом +git diff --name-status HEAD +``` + +### 2. Анализ содержимого изменений + +```bash +# Найти добавленные/удалённые методы +git diff HEAD | grep -E "^\+.*function |^\-.*function " + +# Найти изменённые классы +git diff HEAD | grep -E "^\+.*class |^\-.*class " + +# Найти новые use/import +git diff HEAD | grep -E "^\+.*use |^\+.*import " +``` + +### 3. Для коммитов + +```bash +# Детальный лог с изменениями +git log --stat --oneline -5 + +# Показать что изменено в каждом коммите +git log -p --oneline -3 + +# Группировка по автору +git shortlog -sn --since="1 week ago" +``` + +## Workflow детального анализа + +### Шаг 1: Собрать сырые данные + +```bash +# 1. Список файлов +git diff --name-status HEAD > /tmp/files.txt + +# 2. Статистика строк +git diff --numstat HEAD > /tmp/stats.txt + +# 3. Содержимое изменений для анализа +git diff HEAD > /tmp/diff.txt +``` + +### Шаг 2: Проанализировать каждый файл + +Для каждого изменённого файла определить: + +1. **Тип файла** → категория (Controller, Model, Service, etc.) +2. **Что добавлено** → новые методы, классы, свойства +3. **Что изменено** → модифицированные методы, параметры +4. **Что удалено** → удалённые методы, deprecated код +5. **Зависимости** → что может сломаться + +### Шаг 3: Классифицировать по важности + +| Приоритет | Категория | Описание | +| --------- | --------- | -------- | +| 🔴 Critical | migrations, security, .env | Требует действий | +| 🟠 High | API changes, breaking changes | Влияет на других | +| 🟡 Medium | business logic, services | Основная работа | +| 🟢 Low | docs, tests, refactoring | Улучшения | + +### Шаг 4: Сформировать вывод + +Использовать шаблон выше с заполненными данными. + +## Примеры детального вывода + +### Пример: Изменённый контроллер + +```text +🟡 ИЗМЕНЕНО +─────────────────────────────────────────────────────────────── +~ [OrderController.php](erp24/controllers/OrderController.php) [+45 -12 строк] + │ + ├─ Новые методы: + │ • [actionExport():52](erp24/controllers/OrderController.php#L52) — экспорт заказов в Excel + │ • [actionBulkUpdate():89](erp24/controllers/OrderController.php#L89) — массовое обновление + │ + ├─ Изменённые методы: + │ • [actionIndex():15](erp24/controllers/OrderController.php#L15) — добавлена пагинация + │ • [actionView():34](erp24/controllers/OrderController.php#L34) — добавлено кеширование + │ + ├─ Новые зависимости: + │ • use [ExportService](app/services/ExportService.php) + │ • use [CacheHelper](app/helpers/CacheHelper.php) + │ + └─ Влияние: + • API: новые эндпоинты /order/export, /order/bulk-update + • Права: требуется permission 'order.export' +``` + +### Пример: Новая миграция + +```text +⚠️ ТРЕБУЕТ ВНИМАНИЯ +─────────────────────────────────────────────────────────────── +🗄️ Миграция: [m240128_100000_add_order_status_history.php](migrations/m240128_100000_add_order_status_history.php) + + Создаёт таблицу: order_status_history + ┌─────────────────┬──────────┬─────────────────────┐ + │ Поле │ Тип │ Описание │ + ├─────────────────┼──────────┼─────────────────────┤ + │ id │ int PK │ ID записи │ + │ order_id │ int FK │ Связь с orders │ + │ old_status │ int │ Предыдущий статус │ + │ new_status │ int │ Новый статус │ + │ created_at │ datetime │ Время изменения │ + │ created_by │ int FK │ Кто изменил │ + └─────────────────┴──────────┴─────────────────────┘ + + Действия: + ✅ Применить: php yii migrate + ↩️ Откатить: php yii migrate/down 1 + + ⚠️ Внимание: требуется ~30 сек на больших данных +``` + +## Интеграция с другими skills + +### После /changelog → /git-commit + +Summary используется для формирования commit message: + +```text +feat(orders): add export and bulk update functionality + +- Add actionExport() for Excel export +- Add actionBulkUpdate() for mass status changes +- Improve actionIndex() with pagination and filters +- Add order_status_history migration + +Affects: OrderController, ExportService +Migration: m240128_100000_add_order_status_history +``` + +## Важные правила + +- **ВСЕГДА** читать реальный diff для понимания изменений +- **ДЕТАЛИЗИРОВАТЬ** что именно изменилось (методы, параметры) +- **ВЫДЕЛЯТЬ** критичные изменения (миграции, безопасность, API) +- **ГРУППИРОВАТЬ** по категориям с emoji-маркерами +- **УКАЗЫВАТЬ** влияние изменений на другие части системы +- **ФОРМАТИРОВАТЬ** с использованием рамок и отступов для читаемости diff --git a/.claude/skills/spec-debate/README.md b/.claude/skills/spec-debate/README.md new file mode 100644 index 00000000..4cfba091 --- /dev/null +++ b/.claude/skills/spec-debate/README.md @@ -0,0 +1,148 @@ +# Spec Debate + +Interactive adversarial specification refinement using multiple LLM models. + +## Quick Start + +```bash +# Interactive mode +/spec-debate + +# Review existing spec +/spec-debate ./docs/my-spec.md + +# Generate tech spec from PRD +/spec-debate --from=./prd.md --to=tech-spec +``` + +## Features + +### Custom Focus + +Focus critique on specific aspects: + +```bash +# Predefined focuses +/spec-debate ./spec.md --focus=security +/spec-debate ./spec.md --focus=performance + +# Custom focus (any text) +/spec-debate ./spec.md --focus="API backward compatibility and versioning" +/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting" +``` + +### Document Generation + +Generate new specifications based on existing documents: + +```bash +# Tech spec from PRD +/spec-debate --from=./prd-v3.md --to=tech-spec + +# API spec from tech spec +/spec-debate --from=./tech-spec.md --to=api-spec + +# DB schema from tech spec +/spec-debate --from=./tech-spec.md --to=db-schema + +# Deployment spec from tech spec +/spec-debate --from=./tech-spec.md --to=deployment-spec +``` + +### Combined Mode + +Generate with specific focus: + +```bash +/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design" +/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries" +``` + +### Custom Persona + +Models can critique from a specific professional perspective: + +```bash +# Predefined personas +/spec-debate ./spec.md --persona=security-engineer +/spec-debate ./spec.md --persona=oncall-engineer + +# Custom persona +/spec-debate ./spec.md --persona="retail domain expert with 10 years experience" +/spec-debate ./spec.md --persona="ML engineer focused on data pipelines" +``` + +## Target Document Types + +| Type | Description | Typical Source | +|------|-------------|----------------| +| `prd` | Product Requirements Document | Concept/description | +| `tech-spec` | Technical Specification | PRD | +| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec | +| `db-schema` | Database Schema Documentation | Tech Spec | +| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec | + +## Predefined Focus Modes + +| Mode | Description | +|------|-------------| +| `security` | Auth, encryption, vulnerabilities | +| `scalability` | Scaling, sharding, caching | +| `performance` | Latency, throughput, query optimization | +| `ux` | User journeys, error handling, accessibility | +| `reliability` | Failure modes, circuit breakers, DR | +| `cost` | Infrastructure expenses, resource efficiency | + +## Predefined Personas + +| Persona | Perspective | +|---------|-------------| +| `security-engineer` | Adversarial thinking, paranoid about edge cases | +| `oncall-engineer` | Observability, error messages, 3am debugging | +| `junior-developer` | Flags ambiguity and tribal knowledge | +| `qa-engineer` | Test scenarios, acceptance criteria | +| `site-reliability` | Deployment, monitoring, incident response | +| `product-manager` | User value, success metrics | +| `data-engineer` | Data models, ETL implications | +| `mobile-developer` | API design from mobile perspective | +| `accessibility-specialist` | WCAG compliance | +| `legal-compliance` | GDPR, CCPA, regulatory | + +## All Arguments + +| Argument | Description | +|----------|-------------| +| `` | Path to spec file to review | +| `--focus="..."` | Custom critique focus | +| `--from=` | Source document for generation | +| `--to=` | Target document type | +| `--persona="..."` | Custom persona | +| `--models=` | Comma-separated model list | + +## Examples + +```bash +# Simple review +/spec-debate ./docs/api-spec.md + +# Security-focused review +/spec-debate ./api-spec.md --focus="authentication, authorization, input validation" + +# Generate tech spec from PRD with domain focus +/spec-debate --from=./prd-transcribe-v3.md --to=tech-spec --focus="audio processing pipeline" + +# Review with custom persona +/spec-debate ./tech-spec.md --persona="database administrator with PostgreSQL expertise" + +# Full example +/spec-debate --from=./prd.md --to=tech-spec --focus="scalability" --persona=site-reliability +``` + +## How It Works + +1. **Parse arguments** - Extract flags and options +2. **Find/generate spec** - Load existing or generate from source +3. **Select models** - Uses OpenRouter models by default (5 models) +4. **Run debate loop** - Each model critiques, Claude synthesizes +5. **Iterate** - Repeat until consensus reached +6. **Output** - Final spec written to `spec-output.md` diff --git a/.claude/skills/spec-debate/SKILL.md b/.claude/skills/spec-debate/SKILL.md new file mode 100644 index 00000000..cafe8bb5 --- /dev/null +++ b/.claude/skills/spec-debate/SKILL.md @@ -0,0 +1,397 @@ +--- +name: spec-debate +description: Interactive wrapper for adversarial-spec with UI for selecting spec files, focus modes, personas, and models. Use when you want to run adversarial spec debate with custom options. +allowed-tools: Bash, Read, Write, Glob, AskUserQuestion +--- + +# Spec Debate - Interactive Adversarial Specification + +Launch adversarial spec debates with interactive selection of all options. + +## Execution Flow + +### Step 0: Import Windows Environment Variables (WSL) + +Before checking providers, import API keys from Windows environment: + +```bash +# Get OPENROUTER_API_KEY from Windows and export to current shell +export OPENROUTER_API_KEY="$(cmd.exe /c echo %OPENROUTER_API_KEY% 2>/dev/null | tr -d '\r')" +``` + +### Step 1: Check Available Providers + +First, check which API keys are configured: + +```bash +python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py providers +``` + +Parse output to determine available models. + +### Step 2: Find Spec Files + +Search for potential spec files in the project: + +```bash +find . -maxdepth 3 \( -name "*.md" -o -name "*spec*" -o -name "*prd*" -o -name "*requirements*" \) -type f 2>/dev/null | head -20 +``` + +### Step 3: Interactive Selection + +Use AskUserQuestion to gather all options in sequence: + +#### 3.1 Document Source + +**If `--from` argument provided, skip to 3.1.1 (Generation mode).** + +Ask user: +``` +question: "How do you want to provide the specification?" +header: "Source" +options: + - label: "Select existing file" + description: "Choose from found spec files in the project" + - label: "Generate from document" + description: "Create new spec based on existing PRD/spec (e.g., tech-spec from PRD)" + - label: "Enter file path manually" + description: "Specify custom path to spec file" + - label: "Describe concept" + description: "I'll generate initial spec from your description" +``` + +#### 3.1.1 Generation Mode (if "Generate from document" selected or --from provided) + +First, ask for source document (if not provided via --from): +``` +question: "Which document should be the source?" +header: "Source Doc" +options: [dynamically built from found spec/PRD files] +``` + +Then ask for target type (if not provided via --to): +``` +question: "What type of document do you want to generate?" +header: "Target Type" +options: + - label: "Tech Spec (Recommended)" + description: "Technical specification for developers - architecture, components, APIs" + - label: "API Spec" + description: "REST/GraphQL API specification with endpoints, schemas, examples" + - label: "DB Schema" + description: "Database schema with tables, relations, indexes, migrations" + - label: "Deployment Spec" + description: "Infrastructure, containers, CI/CD, monitoring setup" +``` + +#### 3.2 Document Type + +Ask user: +``` +question: "What type of document are you creating?" +header: "Doc Type" +options: + - label: "PRD (Recommended)" + description: "Product Requirements Document - for stakeholders, PMs, designers" + - label: "Tech Spec" + description: "Technical Specification - for developers and architects" +``` + +#### 3.3 Critique Focus Mode (Optional) + +**If `--focus` argument provided, skip this step and use the provided focus.** + +Ask user: +``` +question: "Do you want to focus the critique on a specific area?" +header: "Focus" +options: + - label: "No specific focus (Recommended)" + description: "General comprehensive critique" + - label: "Custom focus" + description: "Enter your own focus area (e.g., 'API versioning', 'data privacy')" + - label: "Security" + description: "Auth, validation, encryption, vulnerabilities" + - label: "Scalability" + description: "Horizontal scaling, sharding, caching" +``` + +If "Custom focus" selected, ask: +``` +question: "Describe your focus area (what aspects should critiques concentrate on?)" +header: "Custom Focus" +options: (use Other input) +``` + +If user wants different predefined focus, offer second question with remaining options: +- `performance` - Latency, throughput, query optimization +- `ux` - User journeys, error handling, accessibility +- `reliability` - Failure modes, circuit breakers, disaster recovery +- `cost` - Infrastructure expenses, resource efficiency + +#### 3.4 Model Persona (Optional) + +**If `--persona` argument provided, skip this step and use the provided persona.** + +Ask user: +``` +question: "Should models critique from a specific professional perspective?" +header: "Persona" +options: + - label: "No persona (Recommended)" + description: "General critique without role-playing" + - label: "Custom persona" + description: "Enter your own persona (e.g., 'retail domain expert', 'ML engineer')" + - label: "Security Engineer" + description: "Adversarial thinking, paranoid about edge cases" + - label: "On-call Engineer" + description: "Observability, error messages, 3am debugging" +``` + +If "Custom persona" selected, ask: +``` +question: "Describe the persona (what professional perspective should models adopt?)" +header: "Custom Persona" +options: (use Other input) +``` + +If user wants different predefined persona, offer second question with: +- `junior-developer` - Flags ambiguity and tribal knowledge +- `qa-engineer` - Test scenarios, acceptance criteria +- `site-reliability` - Deployment, monitoring, incident response +- `product-manager` - User value, success metrics +- `data-engineer` - Data models, ETL implications +- `mobile-developer` - API design from mobile perspective +- `accessibility-specialist` - WCAG, screen reader support +- `legal-compliance` - GDPR, CCPA, regulatory + +#### 3.5 Model Selection + +**If OPENROUTER_API_KEY is set, use these models by default (skip asking user):** +``` +openrouter/deepseek/deepseek-v3.2,openrouter/google/gemini-3-pro-preview,openrouter/x-ai/grok-4.1-fast,openrouter/z-ai/glm-4.7,openrouter/openai/gpt-5.2 +``` + +Otherwise, based on available providers from Step 1, build options dynamically. + +Use multiSelect to allow choosing multiple models: +``` +question: "Which models should participate in the debate?" +header: "Models" +multiSelect: true +options: [dynamically built based on available API keys] +``` + +**Model mapping by provider:** +- OpenAI: `gpt-4o`, `o1` +- Anthropic: `claude-sonnet-4-20250514`, `claude-opus-4-20250514` +- Google: `gemini/gemini-2.0-flash` +- xAI: `xai/grok-3` +- Mistral: `mistral/mistral-large` +- Groq: `groq/llama-3.3-70b-versatile` +- Deepseek: `deepseek/deepseek-chat` +- OpenRouter (default): `openrouter/deepseek/deepseek-v3.2`, `openrouter/google/gemini-3-pro-preview`, `openrouter/x-ai/grok-4.1-fast`, `openrouter/z-ai/glm-4.7`, `openrouter/openai/gpt-5.2` +- Codex CLI: `codex/gpt-5.2-codex` +- Gemini CLI: `gemini-cli/gemini-3-pro-preview` + +#### 3.6 Additional Options + +Ask user: +``` +question: "Any additional options?" +header: "Options" +multiSelect: true +options: + - label: "Preserve Intent" + description: "Require justification for any content removal" + - label: "Telegram notifications" + description: "Get real-time updates via Telegram bot" + - label: "Save session" + description: "Enable session persistence for resume" +``` + +### Step 4: Generate Initial Spec (if Generation Mode) + +**If `--from` was specified or "Generate from document" was selected:** + +1. Read the source document +2. Generate initial spec based on target type: + +``` +For tech-spec from PRD: +- Extract functional requirements +- Design system architecture +- Define components and their responsibilities +- Specify APIs and data flows +- Document technical constraints and decisions + +For api-spec: +- Extract endpoints from requirements +- Define request/response schemas +- Document authentication and authorization +- Specify error codes and handling + +For db-schema: +- Extract entities from requirements +- Design normalized schema +- Define indexes and constraints +- Document migrations strategy + +For deployment-spec: +- Define container architecture +- Specify resource requirements +- Document CI/CD pipeline +- Define monitoring and alerting +``` + +3. Write generated spec to memory (for debate) + +### Step 5: Build and Execute Command + +Construct the debate command from selections: + +```bash +python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py critique \ + --models MODEL_LIST \ + --doc-type TYPE \ + [--focus FOCUS] \ + [--persona PERSONA] \ + [--preserve-intent] \ + [--telegram] \ + [--session SESSION_NAME] \ + <<'SPEC_EOF' + +SPEC_EOF +``` + +**Note**: For custom focus/persona, pass the exact string provided by user: +```bash +--focus "API backward compatibility and versioning" +--persona "retail domain expert with 10 years experience" +``` + +### Step 6: Run Debate Loop + +Follow the adversarial-spec process: +1. Send spec to selected models +2. Collect critiques +3. Provide your own critique as Claude +4. Synthesize and revise +5. Repeat until all models agree + +### Step 7: Output Results + +When consensus reached: +1. Display final spec +2. Write to `spec-output.md` +3. Show summary with rounds count, contributions + +## Quick Reference + +**Focus Modes:** +| Mode | Description | +|------|-------------| +| security | Auth, encryption, vulnerabilities | +| scalability | Scaling, sharding, caching | +| performance | Latency, throughput | +| ux | User journeys, accessibility | +| reliability | Failure modes, DR | +| cost | Infrastructure costs | + +**Personas:** +| Persona | Perspective | +|---------|-------------| +| security-engineer | Adversarial thinking | +| oncall-engineer | 3am debugging | +| junior-developer | Ambiguity detection | +| qa-engineer | Test scenarios | +| site-reliability | Deployment, monitoring | +| product-manager | User value, metrics | +| data-engineer | Data models, ETL | +| mobile-developer | Mobile API design | +| accessibility-specialist | WCAG compliance | +| legal-compliance | GDPR, CCPA | + +## Arguments + +`$ARGUMENTS` can contain: +- Path to spec file: `/spec-debate ./docs/my-spec.md` +- Description: `/spec-debate "Build a rate limiter service"` +- Custom focus: `/spec-debate --focus="API backward compatibility and versioning"` +- Generate from source: `/spec-debate --from=./prd.md --to=tech-spec` +- Combined: `/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design"` +- Empty: Interactive mode with all questions + +### Argument Parsing + +Parse `$ARGUMENTS` for these flags: +- `--focus=""` - Custom critique focus (overrides interactive selection) +- `--from=` - Source document to base new spec on +- `--to=` - Target document type: `prd`, `tech-spec`, `api-spec`, `db-schema`, `deployment-spec` +- `--persona=""` - Custom persona (overrides interactive selection) +- `--models=` - Comma-separated list of models (overrides default) + +### Generation Modes + +When `--from` is specified, the workflow changes: + +#### Mode: Generate Tech Spec from PRD + +```bash +/spec-debate --from=./prd-v3.md --to=tech-spec +``` + +1. Read source document (PRD) +2. Generate initial tech spec based on PRD requirements +3. Run adversarial debate on generated spec +4. Output: refined tech spec that implements PRD + +#### Mode: Generate API Spec from PRD/Tech Spec + +```bash +/spec-debate --from=./tech-spec.md --to=api-spec +``` + +Generates OpenAPI-style API specification. + +#### Mode: Generate DB Schema from Tech Spec + +```bash +/spec-debate --from=./tech-spec.md --to=db-schema +``` + +Generates database schema documentation with tables, relations, indexes. + +#### Mode: Generate Deployment Spec + +```bash +/spec-debate --from=./tech-spec.md --to=deployment-spec +``` + +Generates deployment/infrastructure specification. + +### Target Types + +| Type | Description | Typical Source | +|------|-------------|----------------| +| `prd` | Product Requirements Document | Concept/description | +| `tech-spec` | Technical Specification | PRD | +| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec | +| `db-schema` | Database Schema Documentation | Tech Spec | +| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec | + +### Custom Focus Examples + +```bash +# Security-focused review +/spec-debate ./api-spec.md --focus="authentication, authorization, input validation" + +# Performance-focused +/spec-debate ./tech-spec.md --focus="query optimization, caching strategy, N+1 problems" + +# Domain-specific +/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting" + +# Generate with focus +/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries and data ownership" +``` diff --git a/.claude/skills/spec-debate/scripts/detect-models.sh b/.claude/skills/spec-debate/scripts/detect-models.sh new file mode 100644 index 00000000..f170b898 --- /dev/null +++ b/.claude/skills/spec-debate/scripts/detect-models.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Detect available models based on configured API keys +# Output: JSON with available models grouped by provider + +set -e + +# Colors for terminal output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if running in JSON mode +JSON_MODE=false +if [[ "$1" == "--json" ]]; then + JSON_MODE=true +fi + +declare -A PROVIDERS +declare -a AVAILABLE_MODELS + +# Check each provider +check_provider() { + local name="$1" + local env_var="$2" + local models="$3" + + if [[ -n "${!env_var}" ]]; then + PROVIDERS[$name]="configured" + IFS=',' read -ra MODEL_ARRAY <<< "$models" + for model in "${MODEL_ARRAY[@]}"; do + AVAILABLE_MODELS+=("$model") + done + return 0 + else + PROVIDERS[$name]="not configured" + return 1 + fi +} + +# Check CLI tools +check_cli() { + local name="$1" + local cmd="$2" + local models="$3" + + if command -v "$cmd" &> /dev/null; then + PROVIDERS[$name]="installed" + IFS=',' read -ra MODEL_ARRAY <<< "$models" + for model in "${MODEL_ARRAY[@]}"; do + AVAILABLE_MODELS+=("$model") + done + return 0 + else + PROVIDERS[$name]="not installed" + return 1 + fi +} + +# Check all providers +check_provider "OpenAI" "OPENAI_API_KEY" "gpt-4o,o1" +check_provider "Anthropic" "ANTHROPIC_API_KEY" "claude-sonnet-4-20250514,claude-opus-4-20250514" +check_provider "Google" "GEMINI_API_KEY" "gemini/gemini-2.0-flash,gemini/gemini-pro" +check_provider "xAI" "XAI_API_KEY" "xai/grok-3,xai/grok-beta" +check_provider "Mistral" "MISTRAL_API_KEY" "mistral/mistral-large,mistral/codestral" +check_provider "Groq" "GROQ_API_KEY" "groq/llama-3.3-70b-versatile" +check_provider "OpenRouter" "OPENROUTER_API_KEY" "openrouter/openai/gpt-4o,openrouter/anthropic/claude-3.5-sonnet" +check_provider "Deepseek" "DEEPSEEK_API_KEY" "deepseek/deepseek-chat" +check_provider "Zhipu" "ZHIPUAI_API_KEY" "zhipu/glm-4,zhipu/glm-4-plus" +check_cli "Codex CLI" "codex" "codex/gpt-5.2-codex,codex/gpt-5.1-codex-max" +check_cli "Gemini CLI" "gemini" "gemini-cli/gemini-3-pro-preview,gemini-cli/gemini-3-flash-preview" + +if $JSON_MODE; then + # JSON output + echo "{" + echo " \"providers\": {" + first=true + for provider in "${!PROVIDERS[@]}"; do + if $first; then + first=false + else + echo "," + fi + printf " \"%s\": \"%s\"" "$provider" "${PROVIDERS[$provider]}" + done + echo "" + echo " }," + echo " \"available_models\": [" + first=true + for model in "${AVAILABLE_MODELS[@]}"; do + if $first; then + first=false + else + echo "," + fi + printf " \"%s\"" "$model" + done + echo "" + echo " ]" + echo "}" +else + # Human-readable output + echo "=== Available Providers ===" + echo "" + for provider in "${!PROVIDERS[@]}"; do + status="${PROVIDERS[$provider]}" + if [[ "$status" == "configured" || "$status" == "installed" ]]; then + echo -e "${GREEN}✓${NC} $provider: $status" + else + echo -e "${RED}✗${NC} $provider: $status" + fi + done + echo "" + echo "=== Available Models ===" + if [[ ${#AVAILABLE_MODELS[@]} -eq 0 ]]; then + echo -e "${YELLOW}No models available. Configure at least one API key.${NC}" + else + for model in "${AVAILABLE_MODELS[@]}"; do + echo " - $model" + done + fi +fi diff --git a/.claude/skills/spec-debate/scripts/run-debate.sh b/.claude/skills/spec-debate/scripts/run-debate.sh new file mode 100644 index 00000000..c500527d --- /dev/null +++ b/.claude/skills/spec-debate/scripts/run-debate.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Wrapper script for running adversarial-spec debates +# Usage: run-debate.sh [options] < spec.md +# +# Options: +# --models MODEL1,MODEL2 Comma-separated list of models +# --doc-type prd|tech Document type (default: tech) +# --focus FOCUS Critique focus area +# --persona PERSONA Model persona +# --preserve-intent Require justification for removals +# --session NAME Session name for persistence +# --telegram Enable Telegram notifications +# --round N Round number (default: 1) +# --press Anti-laziness check + +set -e + +DEBATE_SCRIPT="$HOME/.claude/plugins/marketplaces/adversarial-spec/scripts/debate.py" + +# Check if debate script exists +if [[ ! -f "$DEBATE_SCRIPT" ]]; then + echo "Error: adversarial-spec plugin not found at $DEBATE_SCRIPT" + echo "Install with: /plugin install adversarial-spec" + exit 1 +fi + +# Parse arguments +MODELS="" +DOC_TYPE="tech" +FOCUS="" +PERSONA="" +PRESERVE_INTENT="" +SESSION="" +TELEGRAM="" +ROUND="1" +PRESS="" +CONTEXT_FILES=() + +while [[ $# -gt 0 ]]; do + case $1 in + --models|-m) + MODELS="$2" + shift 2 + ;; + --doc-type|-d) + DOC_TYPE="$2" + shift 2 + ;; + --focus|-f) + FOCUS="$2" + shift 2 + ;; + --persona) + PERSONA="$2" + shift 2 + ;; + --preserve-intent) + PRESERVE_INTENT="--preserve-intent" + shift + ;; + --session|-s) + SESSION="$2" + shift 2 + ;; + --telegram|-t) + TELEGRAM="--telegram" + shift + ;; + --round|-r) + ROUND="$2" + shift 2 + ;; + --press|-p) + PRESS="--press" + shift + ;; + --context|-c) + CONTEXT_FILES+=("--context" "$2") + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Validate required arguments +if [[ -z "$MODELS" ]]; then + echo "Error: --models is required" + echo "Usage: run-debate.sh --models gpt-4o,gemini/gemini-2.0-flash < spec.md" + exit 1 +fi + +# Build command +CMD="python3 $DEBATE_SCRIPT critique" +CMD="$CMD --models $MODELS" +CMD="$CMD --doc-type $DOC_TYPE" +CMD="$CMD --round $ROUND" + +[[ -n "$FOCUS" ]] && CMD="$CMD --focus $FOCUS" +[[ -n "$PERSONA" ]] && CMD="$CMD --persona \"$PERSONA\"" +[[ -n "$PRESERVE_INTENT" ]] && CMD="$CMD $PRESERVE_INTENT" +[[ -n "$SESSION" ]] && CMD="$CMD --session $SESSION" +[[ -n "$TELEGRAM" ]] && CMD="$CMD $TELEGRAM" +[[ -n "$PRESS" ]] && CMD="$CMD $PRESS" + +# Add context files +for ctx in "${CONTEXT_FILES[@]}"; do + CMD="$CMD $ctx" +done + +# Show command being executed +echo "=== Running Debate ===" +echo "Models: $MODELS" +echo "Type: $DOC_TYPE" +[[ -n "$FOCUS" ]] && echo "Focus: $FOCUS" +[[ -n "$PERSONA" ]] && echo "Persona: $PERSONA" +[[ -n "$SESSION" ]] && echo "Session: $SESSION" +echo "======================" +echo "" + +# Execute +eval "$CMD" diff --git a/.gitignore b/.gitignore index 74ddd0c7..f24b13d2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,11 @@ hive-mind-prompt-*.txt # Auto Claude data directory .auto-claude/ + +# Auto Claude generated files +.auto-claude-security.json +.auto-claude-status +.claude_settings.json +.worktrees/ +.security-key +logs/security/ diff --git a/erp24/config/params.php b/erp24/config/params.php index 83f3828f..5ddba1b5 100644 --- a/erp24/config/params.php +++ b/erp24/config/params.php @@ -33,4 +33,15 @@ return [ 'email_recipients' => array_filter(explode(',', getenv('ORDER_CONTROL_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru,ekaterina.geldak@bazacvetov24.ru,irina.rogacheva@bazacvetov24.ru,alena.chelyshkina@bazacvetov24.ru')), 'email_subject' => '[Контроль MP] Отчёт о расхождениях статусов заказов', ], + + // Система обязательных напоминаний о сменах + // Временные окна для показа напоминаний (в формате HH:MM) + 'SHIFT_REMINDER_TIME_WINDOWS' => ['07:50', '08:10', '19:50', '20:10'], + + // Допустимые ключи напоминаний (whitelist для безопасности) + 'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'], + + // ID групп администраторов, которым показываются напоминания + // Пустой массив = все пользователи с записями в timetable + 'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [], ]; diff --git a/erp24/config/web.php b/erp24/config/web.php index b618ca1d..dfa9b18a 100644 --- a/erp24/config/web.php +++ b/erp24/config/web.php @@ -115,6 +115,9 @@ $config = [ 'wiki//' => 'wiki/view', ], ], + 'shiftReminder' => [ + 'class' => 'yii_app\services\ShiftReminderService', + ], ], 'as beforeRequest' => [ 'class' => 'yii\filters\AccessControl', diff --git a/erp24/controllers/ShiftReminderController.php b/erp24/controllers/ShiftReminderController.php new file mode 100644 index 00000000..d85a9c45 --- /dev/null +++ b/erp24/controllers/ShiftReminderController.php @@ -0,0 +1,298 @@ + [ + 'class' => VerbFilter::class, + 'actions' => [ + 'check' => ['POST'], + 'confirm' => ['POST'], + ], + ], + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'allow' => true, + 'roles' => ['@'], // Только авторизованные пользователи + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeAction($action) + { + if (!parent::beforeAction($action)) { + return false; + } + + // Rate limiting + $limits = [ + 'check' => 10, // 10 запросов в минуту + 'confirm' => 5, // 5 запросов в минуту + ]; + + $actionId = $action->id; + if (isset($limits[$actionId])) { + if (!$this->checkRateLimit($actionId, $limits[$actionId])) { + Yii::$app->response->statusCode = 429; + Yii::$app->response->format = Response::FORMAT_JSON; + Yii::$app->response->data = [ + 'success' => false, + 'error' => 'Rate limit exceeded. Please try again later.', + ]; + return false; + } + } + + // Устанавливаем формат ответа JSON для всех действий + Yii::$app->response->format = Response::FORMAT_JSON; + + return true; + } + + /** + * Проверить rate limit для действия + * + * @param string $action Название действия + * @param int $limit Максимальное количество запросов в минуту + * @return bool true если лимит не превышен, false в противном случае + */ + private function checkRateLimit($action, $limit) + { + $userId = Yii::$app->user->id; + $key = "shift_reminder_{$action}_{$userId}"; + $now = time(); + + // Инициализируем хранилище для пользователя, если его нет + if (!isset(self::$rateLimitStorage[$key])) { + self::$rateLimitStorage[$key] = []; + } + + // Удаляем запросы старше 60 секунд + self::$rateLimitStorage[$key] = array_filter( + self::$rateLimitStorage[$key], + function ($timestamp) use ($now) { + return ($now - $timestamp) < 60; + } + ); + + // Проверяем, не превышен ли лимит + if (count(self::$rateLimitStorage[$key]) >= $limit) { + return false; + } + + // Добавляем текущий запрос + self::$rateLimitStorage[$key][] = $now; + + return true; + } + + /** + * Проверить, нужно ли показать напоминание пользователю + * + * POST /shift-reminder/check + * + * Запрос: + * { + * "reminder_key": "day_shift" // опционально, по умолчанию "day_shift" + * } + * + * Ответ (успех): + * { + * "success": true, + * "show_reminder": true, + * "reminder_key": "day_shift", + * "reference_date": "2024-01-27" + * } + * + * Ответ (не нужно показывать): + * { + * "success": true, + * "show_reminder": false + * } + * + * @return array + */ + public function actionCheck() + { + try { + $request = Yii::$app->request; + $userId = Yii::$app->user->id; + + // Получаем reminder_key из запроса или используем дефолтное значение + $reminderKey = $request->post('reminder_key', 'day_shift'); + + // Валидация reminder_key + if (!ShiftReminderService::isValidReminderKey($reminderKey)) { + return [ + 'success' => false, + 'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()), + ]; + } + + // Определяем автоматически правильный reminder_key на основе текущего периода + // Периоды смен: + // 07:50 - 08:09: закрытие ночной смены (night_shift) + // 08:10 - 19:49: дневная смена активна (day_shift) + // 19:50 - 20:09: закрытие дневной смены (day_shift) + // 20:10 - 07:49: ночная смена активна (night_shift) + $currentTime = date('H:i'); + if ($currentTime >= '07:50' && $currentTime < '08:10') { + // Закрытие ночной смены + $reminderKey = 'night_shift'; + } elseif ($currentTime >= '08:10' && $currentTime < '19:50') { + // Дневная смена активна + $reminderKey = 'day_shift'; + } elseif ($currentTime >= '19:50' && $currentTime < '20:10') { + // Закрытие дневной смены + $reminderKey = 'day_shift'; + } else { + // Ночная смена активна (20:10 - 07:49) + $reminderKey = 'night_shift'; + } + + // Проверяем, нужно ли показать напоминание + $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey); + + if ($shouldShow) { + return [ + 'success' => true, + 'show_reminder' => true, + 'reminder_key' => $reminderKey, + 'reference_date' => ShiftReminderService::getReferenceDate(), + ]; + } + + return [ + 'success' => true, + 'show_reminder' => false, + ]; + } catch (\Exception $e) { + Yii::error('Error in ShiftReminderController::actionCheck: ' . $e->getMessage(), 'shift-reminder'); + + return [ + 'success' => false, + 'error' => 'An error occurred while checking reminder status.', + ]; + } + } + + /** + * Подтвердить просмотр напоминания + * + * POST /shift-reminder/confirm + * + * Запрос: + * { + * "reminder_key": "day_shift" + * } + * + * Ответ (успех): + * { + * "success": true, + * "message": "Reminder confirmed successfully.", + * "reference_date": "2024-01-27" + * } + * + * Ответ (ошибка): + * { + * "success": false, + * "error": "Error message" + * } + * + * @return array + */ + public function actionConfirm() + { + try { + $request = Yii::$app->request; + $userId = Yii::$app->user->id; + + // Получаем reminder_key из запроса + $reminderKey = $request->post('reminder_key'); + + // Валидация входных данных + if (empty($reminderKey)) { + return [ + 'success' => false, + 'error' => 'reminder_key is required.', + ]; + } + + // Валидация reminder_key + if (!ShiftReminderService::isValidReminderKey($reminderKey)) { + return [ + 'success' => false, + 'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()), + ]; + } + + // Подтверждаем напоминание + $success = ShiftReminderService::confirmReminder($userId, $reminderKey); + + if ($success) { + $referenceDate = ShiftReminderService::getReferenceDate(); + + // Логируем подтверждение для аудита + Yii::info( + "User {$userId} confirmed reminder '{$reminderKey}' for reference date {$referenceDate}", + 'shift-reminder' + ); + + return [ + 'success' => true, + 'message' => 'Reminder confirmed successfully.', + 'reference_date' => $referenceDate, + ]; + } + + return [ + 'success' => false, + 'error' => 'Failed to confirm reminder. Please try again.', + ]; + } catch (\Exception $e) { + Yii::error('Error in ShiftReminderController::actionConfirm: ' . $e->getMessage(), 'shift-reminder'); + + return [ + 'success' => false, + 'error' => 'An error occurred while confirming reminder.', + ]; + } + } +} diff --git a/erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php b/erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php new file mode 100644 index 00000000..d2094202 --- /dev/null +++ b/erp24/migrations/m260127_105454_create_shift_reminder_shown_table.php @@ -0,0 +1,48 @@ +createTable(self::TABLE_NAME, [ + 'id' => $this->primaryKey(), + 'user_id' => $this->integer()->notNull()->comment('ID пользователя, которому было показано напоминание'), + 'reminder_key' => $this->string(50)->notNull()->comment('Ключ напоминания (идентификатор типа напоминания)'), + 'reference_date' => $this->date()->notNull()->comment('Календарная дата, к которой относится напоминание (для корректной обработки переходов через полночь)'), + 'confirmed_at' => $this->timestamp()->null()->comment('Дата и время подтверждения напоминания пользователем'), + 'created_at' => $this->timestamp()->notNull()->defaultExpression('CURRENT_TIMESTAMP')->comment('Дата создания записи'), + ]); + + // Создаем уникальный составной индекс для предотвращения дублирования напоминаний + $this->createIndex( + 'idx-shift_reminder_shown-user_reminder_date', + self::TABLE_NAME, + ['user_id', 'reminder_key', 'reference_date'], + true + ); + + // Создаем индекс для быстрого поиска по дате подтверждения + $this->createIndex( + 'idx-shift_reminder_shown-confirmed_at', + self::TABLE_NAME, + 'confirmed_at' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown() + { + $this->dropTable(self::TABLE_NAME); + } +} diff --git a/erp24/models/ShiftReminderShown.php b/erp24/models/ShiftReminderShown.php new file mode 100644 index 00000000..f22fd7f3 --- /dev/null +++ b/erp24/models/ShiftReminderShown.php @@ -0,0 +1,84 @@ + 50], + [['reference_date'], 'date', 'format' => 'php:Y-m-d'], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'id' => 'ID', + 'user_id' => 'User ID', + 'reminder_key' => 'Reminder Key', + 'reference_date' => 'Reference Date', + 'confirmed_at' => 'Confirmed At', + 'created_at' => 'Created At', + ]; + } + + /** + * Проверяет, было ли напоминание подтверждено пользователем + * + * @return bool true если напоминание подтверждено, false в противном случае + */ + public function isConfirmed() + { + return $this->confirmed_at !== null; + } + + /** + * Отмечает напоминание как подтвержденное и сохраняет запись + * + * Устанавливает текущее время в поле confirmed_at и сохраняет модель. + * + * @return bool true если сохранение прошло успешно, false в противном случае + */ + public function markAsConfirmed() + { + $this->confirmed_at = date('Y-m-d H:i:s'); + return $this->save(false); + } +} diff --git a/erp24/services/ShiftReminderService.php b/erp24/services/ShiftReminderService.php new file mode 100644 index 00000000..c0a3a107 --- /dev/null +++ b/erp24/services/ShiftReminderService.php @@ -0,0 +1,311 @@ +format('H'); + + // Если время между 00:00 и 05:59, используем предыдущий день + if ($hour >= 0 && $hour < 6) { + $dateTime->modify('-1 day'); + } + + return $dateTime->format('Y-m-d'); + } + + /** + * Проверить, находится ли текущее время в одном из временных окон для показа напоминаний + * + * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + * @param array|null $windows Массив временных окон в формате ['HH:MM', ...]. Если null, используются окна из конфигурации. + * @return bool true если текущее время находится в одном из окон, false в противном случае + */ + public static function isInTimeWindow($currentTime = null, $windows = null) + { + if ($currentTime === null) { + $currentTime = date('Y-m-d H:i:s'); + } + + if ($windows === null) { + $windows = Yii::$app->params['SHIFT_REMINDER_TIME_WINDOWS'] ?? ['07:50', '08:10', '19:50', '20:10']; + } + + $dateTime = new \DateTime($currentTime); + $currentTimeStr = $dateTime->format('H:i'); + + foreach ($windows as $window) { + // Проверяем точное совпадение времени (минута в минуту) + if ($currentTimeStr === $window) { + return true; + } + } + + return false; + } + + /** + * Определить, нужно ли показать напоминание пользователю + * + * Проверяет следующие условия: + * 1. Действие со сменой ещё не выполнено в timetable_fact + * 2. Напоминание не было подтверждено для текущего reminder_key и reference_date + * 3. Пользователь принадлежит к группе администраторов (проверка через timetable) + * + * Напоминание показывается в любое время периода, пока не подтверждено. + * Контроллер определяет правильный reminder_key на основе текущего времени. + * + * @param int $userId ID пользователя + * @param string $reminderKey Ключ напоминания (day_shift или night_shift) + * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + * @return bool true если нужно показать напоминание, false в противном случае + */ + public static function shouldShowReminder($userId, $reminderKey, $currentTime = null) + { + // Проверка 1: Проверяем статус смены в timetable_fact + // Если действие уже выполнено (смена открыта/закрыта) - не показываем напоминание + if (!self::isShiftActionNeeded($userId, $currentTime)) { + return false; + } + + // Проверка 2: Проверяем, не было ли уже подтверждено напоминание для этого reference_date + $referenceDate = self::getReferenceDate($currentTime); + + $existingReminder = ShiftReminderShown::find() + ->where([ + 'user_id' => $userId, + 'reminder_key' => $reminderKey, + 'reference_date' => $referenceDate, + ]) + ->andWhere(['IS NOT', 'confirmed_at', null]) + ->one(); + + if ($existingReminder !== null) { + return false; + } + + // Проверка 3: Пользователь должен быть администратором + if (!self::isUserAdministrator($userId, $currentTime)) { + return false; + } + + return true; + } + + /** + * Проверить, нужно ли выполнить действие со сменой (открытие/закрытие) + * + * Логика: + * - Период закрытия (07:50-08:09, 19:50-20:09): проверяем, есть ли открытая смена для закрытия + * - Период открытия (08:10-19:49, 20:10-07:49): проверяем, не открыта ли уже смена + * + * @param int $userId ID пользователя + * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + * @return bool true если действие нужно выполнить, false если уже выполнено + */ + public static function isShiftActionNeeded($userId, $currentTime = null) + { + if ($currentTime === null) { + $currentTime = date('Y-m-d H:i:s'); + } + + $dateTime = new \DateTime($currentTime); + $currentTimeStr = $dateTime->format('H:i'); + $referenceDate = self::getReferenceDate($currentTime); + + // Определяем, это период закрытия или открытия + // Периоды закрытия: 07:50-08:09 (закрытие ночной), 19:50-20:09 (закрытие дневной) + $isClosingPeriod = ($currentTimeStr >= '07:50' && $currentTimeStr < '08:10') + || ($currentTimeStr >= '19:50' && $currentTimeStr < '20:10'); + + // Получаем последнюю открытую смену пользователя + $openShift = TimetableFactModel::getLast($userId, $referenceDate, true); + + if ($isClosingPeriod) { + // Период закрытия: нужно напомнить, если есть открытая смена (is_opening=true, is_close=false) + if ($openShift === null) { + // Нет открытой смены - нечего закрывать + return false; + } + if ($openShift->is_close) { + // Смена уже закрыта + return false; + } + // Есть открытая смена, которую нужно закрыть + return true; + } else { + // Период открытия: нужно напомнить, если смена ещё не открыта + if ($openShift !== null && !$openShift->is_close) { + // Смена уже открыта и не закрыта - не нужно напоминание об открытии + return false; + } + // Смена не открыта - нужно напомнить об открытии + return true; + } + } + + /** + * Проверить, является ли пользователь администратором на текущую дату + * + * Интеграция с системой timetable: проверяем, есть ли у пользователя + * запланированная рабочая смена на reference_date. + * + * @param int $userId ID пользователя + * @param string|null $currentTime Текущее время. Если null, используется текущее время. + * @return bool true если пользователь является администратором, false в противном случае + */ + public static function isUserAdministrator($userId, $currentTime = null) + { + // Получаем администратора + $admin = Admin::findOne($userId); + if ($admin === null) { + return false; + } + + // Проверяем, принадлежит ли пользователь к административной группе + // Группы администраторов обычно имеют определенные ID + // Получаем их из конфигурации или используем дефолтные значения + $adminGroupIds = Yii::$app->params['SHIFT_REMINDER_ADMIN_GROUP_IDS'] ?? []; + + if (!empty($adminGroupIds) && !in_array($admin->group_id, $adminGroupIds)) { + return false; + } + + // Дополнительная проверка: есть ли у пользователя смена в расписании на reference_date + $referenceDate = self::getReferenceDate($currentTime); + + $hasTimetable = Timetable::find() + ->where([ + 'admin_id' => $userId, + 'date' => $referenceDate, + ]) + ->andWhere([ + 'slot_type_id' => [ + 1, // Timetable::TIMESLOT_WORK - работа + 5, // Timetable::TIMESLOT_INTERNSHIP - стажировка + 8 // Другой тип административной работы + ], + ]) + ->andWhere(['tabel' => 0]) + ->exists(); + + return $hasTimetable; + } + + /** + * Создать или получить запись о показанном напоминании + * + * @param int $userId ID пользователя + * @param string $reminderKey Ключ напоминания + * @param string|null $currentTime Текущее время. Если null, используется текущее время. + * @return ShiftReminderShown|null Модель напоминания или null в случае ошибки + */ + public static function createReminderRecord($userId, $reminderKey, $currentTime = null) + { + $referenceDate = self::getReferenceDate($currentTime); + + // Проверяем, существует ли уже запись + $reminder = ShiftReminderShown::find() + ->where([ + 'user_id' => $userId, + 'reminder_key' => $reminderKey, + 'reference_date' => $referenceDate, + ]) + ->one(); + + if ($reminder !== null) { + return $reminder; + } + + // Создаем новую запись + $reminder = new ShiftReminderShown(); + $reminder->user_id = $userId; + $reminder->reminder_key = $reminderKey; + $reminder->reference_date = $referenceDate; + + if (!$reminder->save()) { + Yii::error('Failed to create shift reminder record: ' . json_encode($reminder->errors), 'shift-reminder'); + return null; + } + + return $reminder; + } + + /** + * Подтвердить напоминание + * + * @param int $userId ID пользователя + * @param string $reminderKey Ключ напоминания + * @param string|null $currentTime Текущее время. Если null, используется текущее время. + * @return bool true если подтверждение прошло успешно, false в противном случае + */ + public static function confirmReminder($userId, $reminderKey, $currentTime = null) + { + $reminder = self::createReminderRecord($userId, $reminderKey, $currentTime); + + if ($reminder === null) { + return false; + } + + if ($reminder->isConfirmed()) { + // Уже подтверждено + return true; + } + + return $reminder->markAsConfirmed(); + } + + /** + * Получить список допустимых ключей напоминаний (whitelist) + * + * @return array Массив допустимых reminder_key + */ + public static function getAllowedReminderKeys() + { + return Yii::$app->params['SHIFT_REMINDER_ALLOWED_KEYS'] ?? ['day_shift', 'night_shift']; + } + + /** + * Проверить, является ли ключ напоминания допустимым + * + * @param string $reminderKey Ключ напоминания для проверки + * @return bool true если ключ допустим, false в противном случае + */ + public static function isValidReminderKey($reminderKey) + { + return in_array($reminderKey, self::getAllowedReminderKeys(), true); + } +} diff --git a/erp24/views/layouts/main.php b/erp24/views/layouts/main.php index 9131424f..8da450fa 100755 --- a/erp24/views/layouts/main.php +++ b/erp24/views/layouts/main.php @@ -10,6 +10,9 @@ app\assets\CachemenutopAsset::register($this); app\assets\JQueryPluginsAsset::register($this); /** @TODO удалить после переписывания основного функционала */ use yii\widgets\Breadcrumbs; + +// Register Shift Reminder JavaScript +$this->registerJsFile('/js/shift-reminder.js', ['position' => \yii\web\View::POS_END]); ?> beginPage() ?> @@ -84,6 +87,9 @@ use yii\widgets\Breadcrumbs; + + render('//shift-reminder/_modal') ?> + endBody(); ?> diff --git a/erp24/views/shift-reminder/_modal.php b/erp24/views/shift-reminder/_modal.php new file mode 100644 index 00000000..fdff20e1 --- /dev/null +++ b/erp24/views/shift-reminder/_modal.php @@ -0,0 +1,204 @@ + + + + + + + diff --git a/erp24/web/js/shift-reminder.js b/erp24/web/js/shift-reminder.js new file mode 100644 index 00000000..6fd54498 --- /dev/null +++ b/erp24/web/js/shift-reminder.js @@ -0,0 +1,490 @@ +/** + * Shift Reminder System + * + * Polls backend every 30 seconds to check if reminder should be shown. + * Displays fullscreen modal with audio alert when reminder is needed. + * Implements day rollover logic and multi-tab coordination. + */ + +(function() { + 'use strict'; + + // CSRF token extraction + const csrfParam = $('meta[name=csrf-param]').attr('content'); + const csrfToken = $('meta[name=csrf-token]').attr('content'); + + // Configuration + const CONFIG = { + pollInterval: 30000, // 30 seconds + maxPollInterval: 240000, // 4 minutes (for exponential backoff) + retryIntervals: [30000, 60000, 120000, 240000], // Exponential backoff + localStorageKey: 'shift_reminder_shown', + audioFrequency: 800, // Hz + audioDuration: 500 // ms + }; + + // State management + let state = { + pollTimer: null, + currentInterval: CONFIG.pollInterval, + retryCount: 0, + isModalVisible: false, + audioContext: null, + bcChannel: null + }; + + /** + * Initialize the shift reminder system + */ + function init() { + // Initialize BroadcastChannel for multi-tab coordination + if (typeof BroadcastChannel !== 'undefined') { + state.bcChannel = new BroadcastChannel('shift_reminder_channel'); + state.bcChannel.onmessage = handleBroadcastMessage; + } + + // Listen to localStorage changes (fallback for browsers without BroadcastChannel) + window.addEventListener('storage', handleStorageChange); + + // Initialize Web Audio API (lazy loading to respect autoplay policies) + initAudioContext(); + + // Start polling + startPolling(); + + // Check immediately on load + checkReminder(); + } + + /** + * Initialize Web Audio API context + */ + function initAudioContext() { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (AudioContext) { + state.audioContext = new AudioContext(); + } + } catch (e) { + console.warn('Web Audio API not supported:', e); + } + } + + /** + * Start polling for reminders + */ + function startPolling() { + if (state.pollTimer) { + clearInterval(state.pollTimer); + } + + state.pollTimer = setInterval(() => { + checkReminder(); + }, state.currentInterval); + } + + /** + * Stop polling + */ + function stopPolling() { + if (state.pollTimer) { + clearInterval(state.pollTimer); + state.pollTimer = null; + } + } + + /** + * Check if reminder should be shown + */ + function checkReminder() { + // Don't check if modal is already visible + if (state.isModalVisible) { + return; + } + + // Check if another tab already handled this reminder + if (isReminderHandledByOtherTab()) { + return; + } + + $.ajax({ + url: '/shift-reminder/check', + type: 'POST', + dataType: 'json', + data: { + [csrfParam]: csrfToken + }, + success: function(response) { + // Reset retry count on successful request + state.retryCount = 0; + state.currentInterval = CONFIG.pollInterval; + + if (response.success && response.show_reminder) { + showReminderModal(response); + } + }, + error: function(xhr, status, error) { + handleRequestError(xhr, status, error); + } + }); + } + + /** + * Handle AJAX request errors with exponential backoff + */ + function handleRequestError(xhr, status, error) { + if (xhr.status === 429) { + // Rate limit exceeded - increase polling interval + state.currentInterval = Math.min( + state.currentInterval * 2, + CONFIG.maxPollInterval + ); + stopPolling(); + startPolling(); + return; + } + + // Network error - implement exponential backoff + if (state.retryCount < CONFIG.retryIntervals.length - 1) { + state.retryCount++; + } + + state.currentInterval = CONFIG.retryIntervals[state.retryCount]; + stopPolling(); + startPolling(); + } + + /** + * Check if reminder was already handled by another tab + */ + function isReminderHandledByOtherTab() { + try { + const stored = localStorage.getItem(CONFIG.localStorageKey); + if (!stored) { + return false; + } + + const data = JSON.parse(stored); + const now = new Date().getTime(); + + // Check if confirmation was within last 5 minutes + if (data.timestamp && (now - data.timestamp) < 300000) { + return true; + } + + // Clean up old data + localStorage.removeItem(CONFIG.localStorageKey); + return false; + } catch (e) { + console.error('Error checking localStorage:', e); + return false; + } + } + + /** + * Show fullscreen reminder modal + */ + function showReminderModal(data) { + if (state.isModalVisible) { + return; + } + + state.isModalVisible = true; + + // Create modal HTML + const modalHtml = ` +
+
+
+ +
+

Напоминание о смене

+

+ ${data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.'} +

+ +
+
+ `; + + // Add modal to DOM + $('body').append(modalHtml); + + // Add modal styles + addModalStyles(); + + // Play audio alert + playAudioAlert(); + + // Notify other tabs + broadcastMessage({ + type: 'modal_shown', + timestamp: new Date().getTime() + }); + + // Handle confirmation + $('#shift-reminder-confirm-btn').on('click', function() { + confirmReminder(data.reminder_key); + }); + } + + /** + * Add CSS styles for fullscreen modal + */ + function addModalStyles() { + if ($('#shift-reminder-styles').length > 0) { + return; + } + + const styles = ` + + `; + + $('head').append(styles); + } + + /** + * Play audio alert using Web Audio API + */ + function playAudioAlert() { + if (!state.audioContext) { + console.warn('Audio context not available'); + return; + } + + try { + // Resume audio context if suspended (autoplay policy) + if (state.audioContext.state === 'suspended') { + state.audioContext.resume().then(() => { + playBeep(); + }).catch((e) => { + console.warn('Could not resume audio context:', e); + }); + } else { + playBeep(); + } + } catch (e) { + console.error('Error playing audio alert:', e); + } + } + + /** + * Play beep sound + */ + function playBeep() { + try { + const oscillator = state.audioContext.createOscillator(); + const gainNode = state.audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(state.audioContext.destination); + + oscillator.frequency.value = CONFIG.audioFrequency; + oscillator.type = 'sine'; + + gainNode.gain.setValueAtTime(0.3, state.audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + state.audioContext.currentTime + CONFIG.audioDuration / 1000 + ); + + oscillator.start(state.audioContext.currentTime); + oscillator.stop(state.audioContext.currentTime + CONFIG.audioDuration / 1000); + } catch (e) { + console.error('Error playing beep:', e); + } + } + + /** + * Confirm reminder viewing + */ + function confirmReminder(reminderKey) { + // Disable button to prevent double-clicks + $('#shift-reminder-confirm-btn').prop('disabled', true).text('Подтверждение...'); + + $.ajax({ + url: '/shift-reminder/confirm', + type: 'POST', + dataType: 'json', + data: { + reminder_key: reminderKey, + [csrfParam]: csrfToken + }, + success: function(response) { + if (response.success) { + // Store confirmation in localStorage for multi-tab coordination + try { + localStorage.setItem(CONFIG.localStorageKey, JSON.stringify({ + reminder_key: reminderKey, + timestamp: new Date().getTime() + })); + } catch (e) { + console.error('Error storing confirmation:', e); + } + + // Notify other tabs + broadcastMessage({ + type: 'confirmed', + reminder_key: reminderKey, + timestamp: new Date().getTime() + }); + + // Close modal + closeModal(); + } else { + alert('Ошибка подтверждения: ' + (response.message || 'Неизвестная ошибка')); + $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить'); + } + }, + error: function(xhr, status, error) { + let errorMessage = 'Ошибка при подтверждении. Попробуйте еще раз.'; + + if (xhr.status === 400 && xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } else if (xhr.status === 429) { + errorMessage = 'Превышен лимит запросов. Пожалуйста, подождите немного.'; + } + + alert(errorMessage); + $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить'); + } + }); + } + + /** + * Close reminder modal + */ + function closeModal() { + $('#shift-reminder-modal').fadeOut(300, function() { + $(this).remove(); + state.isModalVisible = false; + }); + } + + /** + * Broadcast message to other tabs + */ + function broadcastMessage(message) { + if (state.bcChannel) { + try { + state.bcChannel.postMessage(message); + } catch (e) { + console.error('Error broadcasting message:', e); + } + } + } + + /** + * Handle messages from other tabs (BroadcastChannel) + */ + function handleBroadcastMessage(event) { + const data = event.data; + + if (data.type === 'confirmed' || data.type === 'modal_shown') { + // Another tab confirmed or showed modal - close ours if visible + if (state.isModalVisible) { + closeModal(); + } + } + } + + /** + * Handle localStorage changes (fallback for browsers without BroadcastChannel) + */ + function handleStorageChange(event) { + if (event.key === CONFIG.localStorageKey && event.newValue) { + // Another tab confirmed - close our modal if visible + if (state.isModalVisible) { + closeModal(); + } + } + } + + /** + * Cleanup on page unload + */ + function cleanup() { + stopPolling(); + + if (state.bcChannel) { + state.bcChannel.close(); + } + + if (state.audioContext) { + state.audioContext.close(); + } + } + + // Initialize on DOM ready + $(document).ready(function() { + init(); + }); + + // Cleanup on page unload + $(window).on('beforeunload', function() { + cleanup(); + }); + +})();